commit 6c39e0bfbf4fa96c99c55eb3a950a5126df97548 Author: Sunil Thakare Date: Sun Jun 21 20:05:29 2026 +0530 Initial public release v0.1.0 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..7447c99 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,41 @@ +name: Rust + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + + strategy: + matrix: + rust: + - stable + - beta + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Tests + run: cargo test --all + + - name: Release build + run: cargo build --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd20c90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +/target +*.db +*.db-wal +*.db-shm +.env +config.toml +```gitignore +# Rust +/target + +# SQLite +*.db +*.db-wal +*.db-shm + +# Coverage +coverage/ +tarpaulin-report.html + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Local configs +config.toml +.env + +# Temporary backups +backups/ +scratch/ + +# Logs +*.log +``` +.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f59e35e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2517 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nx9-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "axum-extra", + "blake3", + "chrono", + "clap", + "dashmap", + "hex", + "http-body-util", + "rand 0.8.6", + "serde", + "serde_json", + "sqlx", + "thiserror", + "time", + "tokio", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c0d0cc1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,82 @@ +[package] +name = "nx9-auth" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +authors = ["NX9 Team","Sunil Thakare"] +description = "Lightweight self-hosted IAM service for the NX9 ecosystem" +license = "Apache-2.0 or MIT -- Dual License" + +[[bin]] +name = "nx9-auth" +path = "src/main.rs" + +[lib] +name = "nx9_auth" +path = "src/lib.rs" + +[dependencies] +# HTTP framework +axum = { version = "0.8.9", features = ["macros"] } +axum-extra = { version = "0.12", features = ["cookie"] } +tower = { version = "0.5", features = ["full"] } +tower-http = { version = "0.6.11", features = ["trace", "request-id", "compression-gzip", "cors", "set-header"] } + +# Async runtime +tokio = { version = "1.52.3", features = ["full"] } + +# Database +sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite", "chrono", "macros"] } + +# Password hashing +argon2 = "0.5.3" + +# Token/session hashing +blake3 = "1.8.5" + +# CLI +clap = { version = "4.6.1", features = ["derive", "color", "env"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Time +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.23.3", features = ["v4"] } +time = { version = "0.3", features = ["macros"] } + +# Config +toml = "0.8" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] } + +# Random +rand = { version = "0.8", features = ["std", "std_rng"] } + +# Error handling +thiserror = "2.0" +anyhow = "1.0" + +# Rate limiter +dashmap = "6.0" + +# Utilities +hex = "0.4" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true +panic = "abort" + +[profile.dev] +opt-level = 0 +debug = true + +[dev-dependencies] +http-body-util = "0.1" + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5088657 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# --- Stage 1: Build the binary --- +FROM rust:1.85-bookworm AS builder + +WORKDIR /usr/src/nx9-auth + +# 1. Pre-build dependencies for caching +COPY Cargo.toml Cargo.lock ./ +# Create dummy main.rs, lib.rs, and src/bin/bench.rs to compile dependencies first +RUN mkdir -p src/bin src/security src/identity src/db src/api src/audit src/config src/middleware src/error && \ + echo "fn main() {}" > src/main.rs && \ + echo "fn main() {}" > src/bin/bench.rs && \ + echo "" > src/lib.rs && \ + cargo build --release && \ + rm -rf src/ + +# 2. Copy the actual source files and build +COPY . . +# Touch main.rs, lib.rs and src/bin/bench.rs to force cargo to rebuild them with the actual contents +RUN touch src/main.rs src/lib.rs src/bin/bench.rs && \ + cargo build --release + +# --- Stage 2: Run the binary --- +FROM debian:bookworm-slim AS runtime + +# Install CA certificates, curl (for healthcheck), and SQLite CLI +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl sqlite3 && \ + rm -rf /var/lib/apt/lists/* + +# Create a non-root group and user +RUN groupadd -g 10001 nx9-auth && \ + useradd -u 10001 -g nx9-auth -m -s /usr/sbin/nologin nx9-auth + +# Create standard system directories (system mode) +RUN mkdir -p /etc/nx9-auth /var/lib/nx9-auth /var/log/nx9-auth /var/backups/nx9-auth && \ + chown -R nx9-auth:nx9-auth /etc/nx9-auth /var/lib/nx9-auth /var/log/nx9-auth /var/backups/nx9-auth + +# Copy the compiled release binary from builder +COPY --from=builder /usr/src/nx9-auth/target/release/nx9-auth /usr/local/bin/nx9-auth + +# Switch to the non-root user +USER nx9-auth + +# Set standard environment variables +ENV NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml + +# Expose server port +EXPOSE 8655 + +# Set entrypoint +ENTRYPOINT ["/usr/local/bin/nx9-auth"] +CMD ["serve"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f9f538 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# nx9-auth + +A lightweight Identity and Access Management (IAM) service for the NX9 ecosystem. + +Built with Rust, Axum, SQLite, and modern security practices, `nx9-auth` provides authentication, authorization, session management, personal access tokens, audit logging, and role-based access control in a single deployable binary. + +## Features + +* User management +* Role-Based Access Control (RBAC) +* Session authentication +* Personal Access Tokens (PAT) +* Audit logging +* Transaction-safe operations +* SQLite with WAL mode +* Online backups +* Interactive initialization +* Docker and CasaOS support +* Systemd deployment support +* XDG-compliant user mode + +## Quick Start + +Initialize a new installation: + +```bash +nx9-auth init +``` + +Start the server: + +```bash +nx9-auth serve +``` + +Verify health: + +```bash +curl http://127.0.0.1:8655/health +``` + +## CLI Commands + +```bash +nx9-auth init +nx9-auth serve +nx9-auth doctor + +nx9-auth create-user +nx9-auth create-admin + +nx9-auth create-token +nx9-auth revoke-token + +nx9-auth show-user +nx9-auth show-token + +nx9-auth backup +``` + +## Deployment Modes + +### User Mode + +Uses XDG directories: + +```text +~/.config/nx9-auth/ +~/.local/share/nx9-auth/ +~/.local/state/nx9-auth/ +``` + +### System Mode + +```text +/etc/nx9-auth/ +/var/lib/nx9-auth/ +/var/log/nx9-auth/ +``` + +### Docker + +```bash +docker compose up -d +``` + +### CasaOS + +```text +/DATA/AppData/nx9-auth +├── config +├── db +├── state +└── backups +``` + +## Security + +* Argon2id password hashing +* BLAKE3 token hashing +* Session revocation +* Transactional audit logging +* Timing attack mitigation +* Security regression test suite + +## Testing + +```bash +cargo test --all +``` + +Current test coverage includes: + +* Unit tests +* Integration tests +* Security tests +* Migration compatibility tests +* CLI tests + +## Roadmap + +### v0.1.x + +* Stable IAM core +* BZOD integration + +### v0.2.x + +* OAuth2 Authorization Server +* OpenID Connect (OIDC) +* PKCE support + +## License + +Apache 2.0 or MIT -- Dual License + +``` +``` diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..9df6725 --- /dev/null +++ b/build.rs @@ -0,0 +1,41 @@ +use std::process::Command; + +fn main() { + // Git commit hash + let git_commit = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + + // Build timestamp (UTC) + let build_date = Command::new("date") + .args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + + // Rust version + let rust_version = Command::new("rustc") + .arg("--version") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=GIT_COMMIT={git_commit}"); + println!("cargo:rustc-env=BUILD_DATE={build_date}"); + println!("cargo:rustc-env=RUST_VERSION={rust_version}"); + + // Re-run if git HEAD changes + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs/heads"); +} diff --git a/compose.casaos.yml b/compose.casaos.yml new file mode 100644 index 0000000..839a0f4 --- /dev/null +++ b/compose.casaos.yml @@ -0,0 +1,57 @@ +name: nx9-auth +services: + nx9-auth: + image: nx9-auth:0.1.0 + container_name: nx9-auth + restart: unless-stopped + ports: + - "8655:8655" + volumes: + - /DATA/AppData/nx9-auth/config:/etc/nx9-auth + - /DATA/AppData/nx9-auth/db:/var/lib/nx9-auth + - /DATA/AppData/nx9-auth/state:/var/log/nx9-auth + - /DATA/AppData/nx9-auth/backups:/var/backups/nx9-auth + environment: + - NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8655/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + x-casaos: + envs: + - name: NX9_AUTH_CONFIG + description: "Path to configuration file inside container" + value: "/etc/nx9-auth/config.toml" + ports: + - container: "8655" + description: "Internal port for the IAM service API" + volumes: + - container: "/etc/nx9-auth" + description: "Directory containing config.toml" + - container: "/var/lib/nx9-auth" + description: "Directory containing auth.db" + - container: "/var/log/nx9-auth" + description: "Directory containing log and session state files" + - container: "/var/backups/nx9-auth" + description: "Directory containing database backups" + +x-casaos: + architectures: + - amd64 + - arm64 + main: nx9-auth + title: + en_us: NX9 Auth + icon: https://raw.githubusercontent.com/sunil-thakare/nx9-auth/main/icon.png + port_map: "8655" + scheme: http + index: /health + category: Utility + developer: NX9 Team + description: + en_us: Lightweight self-hosted Identity & Access Management (IAM) service for the NX9 ecosystem, featuring SQLite WAL databases, RBAC authorization, sessions, and PAT tokens. + tips: + before_install: + en_us: "After installing, please initialize the database and administrator account by running: docker exec -it nx9-auth nx9-auth init" diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..7aa8c99 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,40 @@ +# nx9-auth Configuration Reference +# Copy this file to /etc/nx9-auth/config.toml and adjust for your environment. + +[server] +# Interface to bind on. Use 127.0.0.1 if running behind a reverse proxy. +host = "0.0.0.0" + +# Port the service listens on. +port = 8655 + +[database] +# Absolute path to the SQLite database file. +# The directory must be writable by the nx9-auth user. +path = "/var/lib/nx9-auth/auth.db" + +[security] +# Session idle timeout in hours. Sessions unused for longer than this are expired. +session_ttl_hours = 24 + +# Session absolute lifetime in days. Sessions older than this are always expired, +# regardless of activity. +session_absolute_ttl_days = 30 + +# Default API token lifetime in days (365 = 1 year). +token_ttl_days = 365 + +# Argon2id memory cost in KiB. Higher = more secure but slower. +# Minimum recommended: 65536 (64 MiB) +argon2_memory = 65536 + +# Argon2id iteration count. Higher = more secure but slower. +argon2_iterations = 3 + +# Argon2id parallelism (number of threads). +argon2_parallelism = 1 + +[audit] +# Enable structured audit logging to the database. +# Disable only in development environments. +enabled = true diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..e865d89 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# deploy.sh — nx9-auth installer for Debian/Ubuntu systems +# +# Usage: sudo bash deploy.sh [path/to/nx9-auth-binary] +# Requires: root, systemd + +set -euo pipefail + +BINARY_PATH="${1:-./target/release/nx9-auth}" +SERVICE_USER="nx9-auth" +INSTALL_BIN="/usr/local/bin/nx9-auth" +CONFIG_DIR="/etc/nx9-auth" +DATA_DIR="/var/lib/nx9-auth" +LOG_DIR="/var/log/nx9-auth" +SERVICE_FILE="/etc/systemd/system/nx9-auth.service" + +# ── Colours ─────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +ok() { echo -e "${GREEN} ✓${NC} $*"; } +warn() { echo -e "${YELLOW} !${NC} $*"; } +fail() { echo -e "${RED} ✗${NC} $*"; exit 1; } + +# ── Prerequisites ───────────────────────────────────────────────────────────── +[[ $EUID -eq 0 ]] || fail "This script must be run as root." +[[ -f "$BINARY_PATH" ]] || fail "Binary not found at: $BINARY_PATH — build with 'cargo build --release' first." + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " nx9-auth deploy" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" + +# ── Create system user ──────────────────────────────────────────────────────── +if id -u "$SERVICE_USER" &>/dev/null; then + warn "System user '$SERVICE_USER' already exists — skipping creation." +else + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" + ok "Created system user: $SERVICE_USER" +fi + +# ── Create directories ──────────────────────────────────────────────────────── +for dir in "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"; do + mkdir -p "$dir" + chown "$SERVICE_USER:$SERVICE_USER" "$dir" + chmod 750 "$dir" +done +ok "Directories created: $CONFIG_DIR, $DATA_DIR, $LOG_DIR" + +# ── Install binary ──────────────────────────────────────────────────────────── +cp "$BINARY_PATH" "$INSTALL_BIN" +chmod 755 "$INSTALL_BIN" +ok "Binary installed: $INSTALL_BIN" + +# ── Write default config if not present ────────────────────────────────────── +if [[ ! -f "$CONFIG_DIR/config.toml" ]]; then + cat > "$CONFIG_DIR/config.toml" <<'EOF' +[server] +host = "0.0.0.0" +port = 8655 + +[database] +path = "/var/lib/nx9-auth/auth.db" + +[security] +session_ttl_hours = 24 +session_absolute_ttl_days = 30 +token_ttl_days = 365 +argon2_memory = 65536 +argon2_iterations = 3 +argon2_parallelism = 1 + +[audit] +enabled = true +EOF + chown root:"$SERVICE_USER" "$CONFIG_DIR/config.toml" + chmod 640 "$CONFIG_DIR/config.toml" + ok "Default config written: $CONFIG_DIR/config.toml" +else + warn "Config already exists — skipping: $CONFIG_DIR/config.toml" +fi + +# ── Install systemd service ─────────────────────────────────────────────────── +cat > "$SERVICE_FILE" <; HttpOnly; Secure; SameSite=Lax; Path=/` +- **Payload**: + ```json + { + "success": true + } + ``` + +--- + +## 2. Session Validation + +To validate an existing session cookie and get the authenticated user's profile, roles, and permissions, make a `GET` request to `/api/v1/auth/me`. + +### Request +- **Method**: `GET` +- **Path**: `/api/v1/auth/me` +- **Headers**: Include the `nx9_session` cookie in the request. + +### Response +- **Status**: `200 OK` +- **Payload**: + ```json + { + "user": { + "id": "e4d3a2b1-5c6d-7e8f-9a0b-1c2d3e4f5a6b", + "username": "admin", + "status": "active", + "last_login_at": "2026-06-21T18:09:13Z", + "created_at": "2026-06-20T12:00:00Z" + }, + "roles": ["admin"], + "permissions": ["users:create", "users:update", "users:delete", "tokens:create", "tokens:revoke"] + } + ``` + +--- + +## 3. Personal Access Token (PAT) Authentication + +For programmatic API access (service-to-service or CLI usage), clients can authenticate using a Personal Access Token (PAT) passed in the `Authorization` header. + +### Request +- **Headers**: `Authorization: Bearer nx9_pat_<64_hex_chars>` + +For example: +```bash +curl -H "Authorization: Bearer nx9_pat_29b2fd8c34f0f089..." https://auth.nx9.local/api/v1/auth/me +``` + +--- + +## 4. Permissions Mapping + +The following table maps BZOD features and features to their required `nx9-auth` permissions: + +| BZOD Feature / Action | Required Permission | Description | +|---|---|---| +| Create link | `links:create` | Allows creating new shortened links | +| Delete link | `links:delete` | Allows deleting existing shortened links | +| View link stats | `links:stats` | Allows viewing redirection analytics and link statistics | +| Create user accounts | `users:create` | Allows administrative user creation | +| Modify user status | `users:update` | Allows enabling, disabling, or locking users | +| Delete user accounts | `users:delete` | Allows soft-deleting/disabling users | + +--- + +## 5. Unified Error Payload + +All `nx9-auth` errors return a unified JSON payload format mapping to standard HTTP status codes: + +```json +{ + "error": "Reason for the error", + "code": "error_code" +} +``` + +### Standard Status Codes & Codes Mapping + +| HTTP Status | Code | Description | Example Error | +|---|---|---|---| +| `401 Unauthorized` | `unauthorized` | Credentials are invalid, or session/token is missing/expired | `{"error": "invalid credentials", "code": "unauthorized"}` | +| `403 Forbidden` | `forbidden` | Authenticated user lacks the required permission | `{"error": "insufficient permissions", "code": "forbidden"}` | +| `404 Not Found` | `not_found` | Resource does not exist | `{"error": "resource not found", "code": "not_found"}` | +| `409 Conflict` | `conflict` | Unique constraint violation (e.g. username taken) | `{"error": "conflict: username already taken", "code": "conflict"}` | +| `422 Unprocessable` | `invalid_input` | Request body or payload format is invalid | `{"error": "invalid input: username cannot be empty", "code": "invalid_input"}` | +| `429 Too Many Requests` | `rate_limited` | Rate limit threshold exceeded | `{"error": "too many requests", "code": "rate_limited"}` | +| `500 Internal Error` | `internal_error` | Database query failure or unexpected server error | `{"error": "internal error", "code": "internal_error"}` | diff --git a/nx9-auth.service b/nx9-auth.service new file mode 100644 index 0000000..8c6be48 --- /dev/null +++ b/nx9-auth.service @@ -0,0 +1,47 @@ +[Unit] +Description=nx9-auth Identity and Access Management Service +Documentation=https://github.com/nx9/nx9-auth +After=network.target +Wants=network.target + +[Service] +Type=simple +User=nx9-auth +Group=nx9-auth +ExecStart=/usr/local/bin/nx9-auth serve --config /etc/nx9-auth/config.toml +Restart=on-failure +RestartSec=5s +TimeoutStopSec=10s + +# Security hardening +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true +CapabilityBoundingSet= +AmbientCapabilities= +LockPersonality=true +MemoryDenyWriteExecute=true +PrivateDevices=true +ProtectClock=true +ProtectControlGroups=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +SystemCallArchitectures=native +SystemCallFilter=@system-service + +# Writable paths (everything else is read-only via ProtectSystem=strict) +ReadWritePaths=/var/lib/nx9-auth /var/log/nx9-auth + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=nx9-auth + +[Install] +WantedBy=multi-user.target diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..859e0c2 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 (e.g., v0.1.0-beta1)" >&2 + exit 1 +fi + +TAG_VERSION="$1" +# Ensure it starts with v +if [[ ! "$TAG_VERSION" =~ ^v ]]; then + echo "Error: version-tag must start with 'v' (e.g., v0.1.0-beta1)" >&2 + exit 1 +fi + +# Strip the leading 'v' +STRIPPED_VERSION="${TAG_VERSION#v}" + +# Extract version from Cargo.toml +CARGO_VERSION=$(grep -m 1 "^version = " Cargo.toml | awk -F '"' '{print $2}') + +if [ "$STRIPPED_VERSION" != "$CARGO_VERSION" ]; then + echo "Error: Version mismatch! Git tag version ($STRIPPED_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" >&2 + exit 1 +fi + +echo "=== 1. Checking code formatting ===" +cargo fmt --check + +echo "=== 2. Running clippy ===" +cargo clippy --all-targets -- -D warnings + +echo "=== 3. Running test suite ===" +cargo test + +echo "=== 4. Building release binary ===" +cargo build --release + +echo "=== 5. Packaging release ===" +rm -rf dist +mkdir -p dist + +# Copy binary +cp target/release/nx9-auth dist/ +strip dist/nx9-auth || true + +# Create VERSION file +echo "$TAG_VERSION" > dist/VERSION + +# Generate SHA256SUMS +cd dist +sha256sum nx9-auth VERSION > SHA256SUMS +cd .. + +echo "=== Release $TAG_VERSION packaged successfully in dist/ ===" +ls -l dist/ diff --git a/src/api/auth.rs b/src/api/auth.rs new file mode 100644 index 0000000..509f385 --- /dev/null +++ b/src/api/auth.rs @@ -0,0 +1,233 @@ +use axum::{Json, extract::State}; +use axum_extra::extract::{CookieJar, cookie::Cookie}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ + audit::{self, AuditEvent}, + db::models::AuditSeverity, + db::repository::users as user_repo, + error::{AppError, Result}, + identity::{permissions, roles}, + middleware::{audit::AuditContext, auth::AuthUser}, + security::{passwords, sessions}, + state::AppState, +}; + +// ── Login ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +/// POST /api/v1/auth/login +pub async fn login( + State(state): State, + ctx: AuditContext, + jar: CookieJar, + Json(body): Json, +) -> Result<(CookieJar, Json)> { + let ip = ctx.ip_address.as_deref(); + + // Rate limit check + if let Some(ip_str) = &ctx.ip_address { + if let Ok(ip_addr) = ip_str.parse::() { + state.rate_limiter.check(ip_addr)?; + } + } + + // Look up user + let user_opt = user_repo::find_by_username(&state.pool, &body.username) + .await + .map_err(AppError::Database)?; + + let mut is_authed = false; + let mut final_user = None; + + if let Some(user) = user_opt { + let password_ok = passwords::verify_password(&body.password, &user.password_hash)?; + if password_ok && user.is_active() { + is_authed = true; + final_user = Some(user); + } + } else { + // Run dummy verify to take same execution time + passwords::verify_dummy(&state.config.security)?; + } + + if !is_authed { + record_login_failure(&state, &body.username, ip, ctx.user_agent.as_deref()).await; + if let Some(ip_str) = &ctx.ip_address { + if let Ok(ip_addr) = ip_str.parse::() { + state.rate_limiter.record_failure(ip_addr); + } + } + return Err(AppError::Unauthorized); + } + + let user = final_user.unwrap(); + + // Clear rate limit on success + if let Some(ip_str) = &ctx.ip_address { + if let Ok(ip_addr) = ip_str.parse::() { + state.rate_limiter.record_success(ip_addr); + } + } + + // Create session + let (session, raw_token) = sessions::create_session( + &state.pool, + &user.id, + ip, + ctx.user_agent.as_deref(), + &state.config.security, + ) + .await?; + + // Update last_login_at and audit in the same transaction + if let Ok(mut tx) = state.pool.begin().await { + let _ = user_repo::set_last_login(&mut tx, &user.id).await; + let _ = audit::log( + &mut tx, + AuditEvent { + actor_id: Some(&user.id), + target_id: Some(&user.id), + action: "login_success", + resource_type: "session", + resource_id: Some(&session.id), + severity: AuditSeverity::Info, + ip, + ua: ctx.user_agent.as_deref(), + metadata: None, + }, + ) + .await; + let _ = tx.commit().await; + } + + tracing::info!( + event = "login_success", + user_id = %user.id, + username = %user.username, + ip = ip.unwrap_or("unknown"), + ); + + // Build secure session cookie using time::Duration for max_age + let max_age_secs = state.config.security.session_absolute_ttl_days as i64 * 86400; + let mut cookie = Cookie::new(sessions::SESSION_COOKIE, raw_token); + cookie.set_http_only(true); + cookie.set_secure(true); + cookie.set_same_site(axum_extra::extract::cookie::SameSite::Lax); + cookie.set_path("/"); + cookie.set_max_age(time::Duration::seconds(max_age_secs)); + + Ok((jar.add(cookie), Json(json!({ "success": true })))) +} + +async fn record_login_failure( + state: &AppState, + username: &str, + ip: Option<&str>, + ua: Option<&str>, +) { + if let Ok(mut tx) = state.pool.begin().await { + let _ = audit::log( + &mut tx, + AuditEvent { + actor_id: None, + target_id: None, + action: "login_failed", + resource_type: "session", + resource_id: None, + severity: AuditSeverity::Warning, + ip, + ua, + metadata: Some(&format!(r#"{{"username":"{}"}}"#, username)), + }, + ) + .await; + let _ = tx.commit().await; + } + + tracing::warn!( + event = "login_failed", + username = %username, + ip = ip.unwrap_or("unknown"), + ); +} + +// ── Logout ──────────────────────────────────────────────────────────────────── + +/// POST /api/v1/auth/logout +pub async fn logout( + State(state): State, + auth: AuthUser, + jar: CookieJar, +) -> Result<(CookieJar, Json)> { + if let Some(session_id) = &auth.session_id { + sessions::revoke_session(&state.pool, session_id).await?; + + // Audit log for logout + if let Ok(mut tx) = state.pool.begin().await { + let _ = audit::log( + &mut tx, + AuditEvent { + actor_id: Some(&auth.user.id), + target_id: Some(&auth.user.id), + action: "logout", + resource_type: "session", + resource_id: Some(session_id), + severity: AuditSeverity::Info, + ip: None, + ua: None, + metadata: None, + }, + ) + .await; + let _ = tx.commit().await; + } + } + + let mut removal = Cookie::from(sessions::SESSION_COOKIE); + removal.set_path("/"); + let removed = jar.remove(removal); + Ok((removed, Json(json!({ "success": true })))) +} + +// ── Me ──────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct MeResponse { + pub user: UserView, + pub roles: Vec, + pub permissions: Vec, +} + +#[derive(Serialize)] +pub struct UserView { + pub id: String, + pub username: String, + pub status: String, + pub last_login_at: Option, + pub created_at: String, +} + +/// GET /api/v1/auth/me +pub async fn me(State(state): State, auth: AuthUser) -> Result> { + let user_roles = roles::list_user_roles(&state.pool, &auth.user.id).await?; + let user_perms = permissions::list_user_permissions(&state.pool, &auth.user.id).await?; + + Ok(Json(MeResponse { + user: UserView { + id: auth.user.id.clone(), + username: auth.user.username.clone(), + status: auth.user.status().to_string(), + last_login_at: auth.user.last_login_at.clone(), + created_at: auth.user.created_at.clone(), + }, + roles: user_roles.into_iter().map(|r| r.name).collect(), + permissions: user_perms, + })) +} diff --git a/src/api/health.rs b/src/api/health.rs new file mode 100644 index 0000000..da125e5 --- /dev/null +++ b/src/api/health.rs @@ -0,0 +1,7 @@ +use axum::Json; +use serde_json::{Value, json}; + +/// GET /health +pub async fn health() -> Json { + Json(json!({ "status": "ok" })) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..38bf53f --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod health; +pub mod router; +pub mod tokens; +pub mod users; +pub mod version; diff --git a/src/api/router.rs b/src/api/router.rs new file mode 100644 index 0000000..0d45312 --- /dev/null +++ b/src/api/router.rs @@ -0,0 +1,51 @@ +use axum::{ + Router, + routing::{delete, get, post}, +}; +use tower_http::{ + compression::CompressionLayer, + cors::{Any, CorsLayer}, + trace::TraceLayer, +}; + +use crate::{ + api::{auth, health, tokens, users, version}, + state::AppState, +}; + +/// Build the full Axum application router. +pub fn build(state: AppState) -> Router { + let api_v1 = Router::new() + // Auth + .route("/auth/login", post(auth::login)) + .route("/auth/logout", post(auth::logout)) + .route("/auth/me", get(auth::me)) + // Users + .route("/users", get(users::list_users).post(users::create_user)) + .route( + "/users/{id}", + get(users::get_user) + .patch(users::update_user) + .delete(users::delete_user), + ) + // Tokens + .route( + "/tokens", + get(tokens::list_tokens).post(tokens::create_token), + ) + .route("/tokens/{id}", delete(tokens::revoke_token)); + + Router::new() + .route("/health", get(health::health)) + .route("/version", get(version::version)) + .nest("/api/v1", api_v1) + .layer(TraceLayer::new_for_http()) + .layer(CompressionLayer::new()) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state) +} diff --git a/src/api/tokens.rs b/src/api/tokens.rs new file mode 100644 index 0000000..7c7c497 --- /dev/null +++ b/src/api/tokens.rs @@ -0,0 +1,128 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ + db::models::ApiToken, + db::repository::tokens as token_repo, + error::{AppError, Result}, + middleware::{audit::AuditContext, auth::AuthUser, permissions::require}, + security::tokens as token_security, + state::AppState, +}; + +// ── Response type ───────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct TokenResponse { + pub id: String, + pub name: String, + pub last_used_at: Option, + pub expires_at: Option, + pub created_at: String, + pub revoked: bool, +} + +impl From for TokenResponse { + fn from(t: ApiToken) -> Self { + Self { + id: t.id, + name: t.name, + last_used_at: t.last_used_at, + expires_at: t.expires_at, + created_at: t.created_at, + revoked: t.revoked, + } + } +} + +// ── POST /api/v1/tokens ─────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct CreateTokenRequest { + pub name: String, +} + +/// Create a personal access token for the authenticated user. +/// +/// The raw token is returned **once** in this response and never stored. +pub async fn create_token( + State(state): State, + auth: AuthUser, + ctx: AuditContext, + Json(body): Json, +) -> Result> { + if body.name.trim().is_empty() { + return Err(AppError::InvalidInput("token name cannot be empty".into())); + } + + let (token, raw) = token_security::create_token( + &state.pool, + &auth.user.id, + &body.name, + &state.config.security, + Some(&auth.user.id), + ctx.ip_address.as_deref(), + ctx.user_agent.as_deref(), + ) + .await?; + + tracing::info!( + event = "token_created", + user_id = %auth.user.id, + token_id = %token.id, + name = %token.name, + ); + + Ok(Json(json!({ + "token": TokenResponse::from(token), + "raw_token": raw, + "warning": "Store this token securely — it will not be shown again.", + }))) +} + +// ── GET /api/v1/tokens ──────────────────────────────────────────────────────── + +/// List the authenticated user's own tokens. +pub async fn list_tokens(State(state): State, auth: AuthUser) -> Result> { + let tokens = token_repo::list_for_user(&state.pool, &auth.user.id) + .await + .map_err(AppError::Database)?; + + let views: Vec = tokens.into_iter().map(TokenResponse::from).collect(); + Ok(Json(json!({ "tokens": views }))) +} + +// ── DELETE /api/v1/tokens/:id ──────────────────────────────────────────────── + +/// Revoke a token. The caller must own the token or hold `tokens:revoke`. +pub async fn revoke_token( + State(state): State, + auth: AuthUser, + ctx: AuditContext, + Path(id): Path, +) -> Result> { + let token = token_repo::find_by_id(&state.pool, &id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound)?; + + // Must be owner or have tokens:revoke permission + if token.user_id != auth.user.id { + require(&state.pool, &auth.user.id, "tokens:revoke").await?; + } + + token_security::revoke_token( + &state.pool, + &id, + Some(&auth.user.id), + ctx.ip_address.as_deref(), + ctx.user_agent.as_deref(), + ) + .await?; + + Ok(Json(json!({ "success": true }))) +} diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..ee6c38e --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,166 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{ + db::models::Tenant, + db::models::{User, UserStatus}, + error::{AppError, Result}, + identity::users as identity, + middleware::{audit::AuditContext, auth::AuthUser, permissions::require}, + state::AppState, +}; + +// ── Response type ───────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct UserResponse { + pub id: String, + pub username: String, + pub status: String, + pub last_login_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From for UserResponse { + fn from(u: User) -> Self { + Self { + id: u.id, + username: u.username, + status: UserStatus::from_i32(u.status).to_string(), + last_login_at: u.last_login_at, + created_at: u.created_at, + updated_at: u.updated_at, + } + } +} + +// ── GET /api/v1/users ───────────────────────────────────────────────────────── + +pub async fn list_users(State(state): State, auth: AuthUser) -> Result> { + require(&state.pool, &auth.user.id, "users:create").await?; + + let users = identity::list_users(&state.pool, Tenant::DEFAULT_ID).await?; + let views: Vec = users.into_iter().map(UserResponse::from).collect(); + Ok(Json(json!({ "users": views }))) +} + +// ── POST /api/v1/users ──────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct CreateUserRequest { + pub username: String, + pub password: String, +} + +pub async fn create_user( + State(state): State, + auth: AuthUser, + ctx: AuditContext, + Json(body): Json, +) -> Result> { + require(&state.pool, &auth.user.id, "users:create").await?; + + let user = identity::create_user( + &state.pool, + &state.config.security, + Tenant::DEFAULT_ID, + &body.username, + &body.password, + Some(&auth.user.id), + ctx.ip_address.as_deref(), + ctx.user_agent.as_deref(), + ) + .await?; + + Ok(Json(json!({ "user": UserResponse::from(user) }))) +} + +// ── GET /api/v1/users/:id ───────────────────────────────────────────────────── + +pub async fn get_user( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> Result> { + // Users may view themselves; admins may view anyone + if id != auth.user.id { + require(&state.pool, &auth.user.id, "users:create").await?; + } + + let user = identity::get_user(&state.pool, &id).await?; + Ok(Json(json!({ "user": UserResponse::from(user) }))) +} + +// ── PATCH /api/v1/users/:id ─────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct UpdateUserRequest { + pub status: Option, +} + +pub async fn update_user( + State(state): State, + auth: AuthUser, + ctx: AuditContext, + Path(id): Path, + Json(body): Json, +) -> Result> { + require(&state.pool, &auth.user.id, "users:update").await?; + + if let Some(status_str) = &body.status { + let status = match status_str.as_str() { + "active" => UserStatus::Active as i32, + "disabled" => UserStatus::Disabled as i32, + "locked" => UserStatus::Locked as i32, + other => return Err(AppError::InvalidInput(format!("unknown status: {other}"))), + }; + identity::update_status( + &state.pool, + &id, + status, + Some(&auth.user.id), + ctx.ip_address.as_deref(), + ctx.user_agent.as_deref(), + ) + .await?; + } + + let user = identity::get_user(&state.pool, &id).await?; + Ok(Json(json!({ "user": UserResponse::from(user) }))) +} + +// ── DELETE /api/v1/users/:id ────────────────────────────────────────────────── + +/// Soft-deletes a user by setting status = Disabled. Never hard-deletes. +pub async fn delete_user( + State(state): State, + auth: AuthUser, + ctx: AuditContext, + Path(id): Path, +) -> Result> { + require(&state.pool, &auth.user.id, "users:delete").await?; + + // Prevent self-deletion + if id == auth.user.id { + return Err(AppError::InvalidInput( + "cannot disable your own account".into(), + )); + } + + identity::update_status( + &state.pool, + &id, + UserStatus::Disabled as i32, + Some(&auth.user.id), + ctx.ip_address.as_deref(), + ctx.user_agent.as_deref(), + ) + .await?; + + Ok(Json(json!({ "success": true }))) +} diff --git a/src/api/version.rs b/src/api/version.rs new file mode 100644 index 0000000..dc9d74a --- /dev/null +++ b/src/api/version.rs @@ -0,0 +1,15 @@ +use axum::Json; +use serde_json::{Value, json}; + +/// GET /version +/// +/// Returns build metadata baked in at compile time via `build.rs`. +pub async fn version() -> Json { + Json(json!({ + "name": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "git_commit": env!("GIT_COMMIT"), + "build_date": env!("BUILD_DATE"), + "rust_version": env!("RUST_VERSION"), + })) +} diff --git a/src/audit/audit.rs b/src/audit/audit.rs new file mode 100644 index 0000000..9c587d9 --- /dev/null +++ b/src/audit/audit.rs @@ -0,0 +1,84 @@ +use crate::{ + db::{models::AuditSeverity, repository::audit as repo}, + error::AppError, +}; + +/// A structured audit event to be persisted and logged. +#[derive(Debug)] +pub struct AuditEvent<'a> { + /// The user performing the action (None for system/CLI events). + pub actor_id: Option<&'a str>, + /// The user being acted upon, if applicable. + pub target_id: Option<&'a str>, + /// Machine-readable action name (e.g. `"login_success"`, `"user_created"`). + pub action: &'a str, + /// Resource category (e.g. `"user"`, `"session"`, `"token"`). + pub resource_type: &'a str, + /// Specific resource ID, if applicable. + pub resource_id: Option<&'a str>, + /// Event severity. + pub severity: AuditSeverity, + /// Client IP address. + pub ip: Option<&'a str>, + /// Client User-Agent string. + pub ua: Option<&'a str>, + /// Optional structured metadata (serialized JSON string). + pub metadata: Option<&'a str>, +} + +impl<'a> AuditEvent<'a> { + /// Convenience constructor for info-level system events with no actor/IP. + pub fn system(action: &'a str, resource_type: &'a str) -> Self { + Self { + actor_id: None, + target_id: None, + action, + resource_type, + resource_id: None, + severity: AuditSeverity::Info, + ip: None, + ua: None, + metadata: None, + } + } +} + +/// Persist an audit event to the database and emit a structured log line. +/// +/// This function is intentionally fire-and-forget — a failure to write an +/// audit log must never break an otherwise successful operation. +pub async fn log( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + event: AuditEvent<'_>, +) -> Result<(), AppError> { + let id = uuid::Uuid::new_v4().to_string(); + + tracing::info!( + event = "audit", + action = event.action, + resource_type = event.resource_type, + resource_id = event.resource_id, + severity = event.severity.as_str(), + actor_id = event.actor_id, + target_id = event.target_id, + ip = event.ip, + ); + + repo::insert( + tx, + &id, + event.actor_id, + event.target_id, + event.action, + event.resource_type, + event.resource_id, + event.severity.as_str(), + event.ip, + event.ua, + event.metadata, + ) + .await + .map_err(AppError::Database)?; + + Ok(()) +} diff --git a/src/audit/mod.rs b/src/audit/mod.rs new file mode 100644 index 0000000..e1d71ee --- /dev/null +++ b/src/audit/mod.rs @@ -0,0 +1,3 @@ +#[allow(clippy::module_inception)] +pub mod audit; +pub use audit::{AuditEvent, log}; diff --git a/src/bin/bench.rs b/src/bin/bench.rs new file mode 100644 index 0000000..8042d5a --- /dev/null +++ b/src/bin/bench.rs @@ -0,0 +1,178 @@ +use nx9_auth::{ + config::SecurityConfig, + db::{self, models::Tenant}, + identity::users as identity_users, + security::{passwords, sessions, tokens}, +}; +use sqlx::SqlitePool; +use std::time::Instant; + +async fn setup_bench_db() -> (SqlitePool, String) { + let db_id = uuid::Uuid::new_v4().to_string(); + let db_path = format!("target/bench_{}.db", db_id); + let pool = db::create_pool(&db_path) + .await + .expect("Failed to create bench db"); + db::run_migrations(&pool) + .await + .expect("Failed to run bench migrations"); + (pool, db_path) +} + +fn print_stats(name: &str, mut durations: Vec, count: usize) { + durations.sort(); + let total_secs: f64 = durations.iter().map(|d| d.as_secs_f64()).sum(); + let qps = count as f64 / total_secs; + + let p50 = durations[count / 2]; + let p95 = durations[(count * 95) / 100]; + let p99 = durations[(count * 99) / 100]; + + println!("{}:", name); + println!(" Total ops: {}", count); + println!(" Requests/s: {:.2}", qps); + println!(" P50 latency: {:.2} ms", p50.as_secs_f64() * 1000.0); + println!(" P95 latency: {:.2} ms", p95.as_secs_f64() * 1000.0); + println!(" P99 latency: {:.2} ms", p99.as_secs_f64() * 1000.0); + println!(); +} + +#[tokio::main] +async fn main() { + println!("Starting nx9-auth microbenchmarks..."); + let (pool, db_path) = setup_bench_db().await; + + // Production security config + let sec_cfg = SecurityConfig { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 65536, // Production: 64MiB + argon2_iterations: 3, // Production: 3 passes + argon2_parallelism: 1, // Production: 1 thread + }; + + // Test security config (low cost to see algorithm overhead vs Argon2 KDF) + let fast_sec_cfg = SecurityConfig { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 4096, + argon2_iterations: 1, + argon2_parallelism: 1, + }; + + // Create benchmark user + let user = identity_users::create_user( + &pool, + &fast_sec_cfg, + Tenant::DEFAULT_ID, + "bench_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + // ───────────────────────────────────────────────────────────────────────── + // 1. Password Verification Benchmark (Production Cost) + // ───────────────────────────────────────────────────────────────────────── + let prod_hash = passwords::hash_password("super_secure_passphrase_123", &sec_cfg).unwrap(); + let login_ops = 20; + let mut login_durations = Vec::with_capacity(login_ops); + + for _ in 0..login_ops { + let start = Instant::now(); + let ok = passwords::verify_password("super_secure_passphrase_123", &prod_hash).unwrap(); + assert!(ok); + login_durations.push(start.elapsed()); + } + print_stats( + "Argon2id Password Verification (Production Config: 64MiB, 3 passes)", + login_durations, + login_ops, + ); + + // ───────────────────────────────────────────────────────────────────────── + // 2. Password Verification Benchmark (Low Cost) + // ───────────────────────────────────────────────────────────────────────── + let fast_hash = passwords::hash_password("super_secure_passphrase_123", &fast_sec_cfg).unwrap(); + let fast_login_ops = 100; + let mut fast_login_durations = Vec::with_capacity(fast_login_ops); + + for _ in 0..fast_login_ops { + let start = Instant::now(); + let ok = passwords::verify_password("super_secure_passphrase_123", &fast_hash).unwrap(); + assert!(ok); + fast_login_durations.push(start.elapsed()); + } + print_stats( + "Argon2id Password Verification (Test/Low Cost Config: 4MiB, 1 pass)", + fast_login_durations, + fast_login_ops, + ); + + // ───────────────────────────────────────────────────────────────────────── + // 3. Session Validation Benchmark (BLAKE3 Hashing + SQLite) + // ───────────────────────────────────────────────────────────────────────── + let (_session, raw_token) = sessions::create_session( + &pool, + &user.id, + Some("127.0.0.1"), + Some("Bench Agent"), + &fast_sec_cfg, + ) + .await + .unwrap(); + + let session_ops = 2000; + let mut session_durations = Vec::with_capacity(session_ops); + + for _ in 0..session_ops { + let start = Instant::now(); + let validated = sessions::validate_session(&pool, &raw_token, &fast_sec_cfg) + .await + .unwrap(); + assert!(validated.is_some()); + session_durations.push(start.elapsed()); + } + print_stats( + "Session Validation (BLAKE3 + SQLite Touch)", + session_durations, + session_ops, + ); + + // ───────────────────────────────────────────────────────────────────────── + // 4. Personal Access Token (PAT) Verification Benchmark (BLAKE3 + SQLite) + // ───────────────────────────────────────────────────────────────────────── + let (_token, raw_pat) = tokens::create_token( + &pool, + &user.id, + "bench-pat", + &fast_sec_cfg, + None, + None, + None, + ) + .await + .unwrap(); + + let pat_ops = 2000; + let mut pat_durations = Vec::with_capacity(pat_ops); + + for _ in 0..pat_ops { + let start = Instant::now(); + let validated = tokens::validate_token(&pool, &raw_pat).await.unwrap(); + assert!(validated.is_some()); + pat_durations.push(start.elapsed()); + } + print_stats( + "PAT Validation (BLAKE3 + SQLite Touch)", + pat_durations, + pat_ops, + ); + + let _ = std::fs::remove_file(db_path); +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..adc5ddf --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,1020 @@ +use std::io::{self, Write}; +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +use crate::{ + config::Config, + db::repository::{roles as role_repo, users as user_repo}, + db::{ + self, + models::{Tenant, UserStatus}, + }, + error::AppError, + identity::{roles, users as identity_users}, + security::tokens as token_security, +}; + +// ── CLI Definition ──────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command( + name = "nx9-auth", + about = "NX9 Identity and Access Management service", + version = env!("CARGO_PKG_VERSION"), + author, +)] +pub struct Cli { + /// Path to the configuration file. + #[arg(long, short, global = true, env = "NX9_AUTH_CONFIG")] + pub config: Option, + + /// Enable verbose logging for CLI commands. + #[arg(long, short, global = true)] + pub verbose: bool, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Start the HTTP server. + Serve, + + /// Run pending database migrations. + Migrate, + + /// Check system health and configuration. + Doctor, + + /// Create an administrator user. + CreateAdmin { + /// Username for the new admin account. + username: String, + }, + + /// Create a standard user. + CreateUser { + /// Username for the new user account. + username: String, + }, + + /// List all users in the system. + ListUsers, + + /// Disable a user account (sets status = disabled). + DisableUser { + /// User ID to disable. + id: String, + }, + + /// Enable a user account (sets status = active). + EnableUser { + /// User ID to enable. + id: String, + }, + + /// Reset a user's password. + ResetPassword { + /// User ID to reset. + id: String, + }, + + /// Create a personal access token for a user. + CreateToken { + /// User ID to create the token for. + #[arg(long)] + user: String, + /// Descriptive name for the token. + #[arg(long, default_value = "CLI token")] + name: String, + }, + + /// Revoke a personal access token by ID. + RevokeToken { + /// Token ID to revoke. + id: String, + }, + + /// Initialize the configuration, directories, database and admin user. + Init { + /// Run in non-interactive mode. + #[arg(long)] + non_interactive: bool, + + /// Skip administrator user creation. + #[arg(long)] + skip_admin: bool, + + /// Force initialization even if already initialized (overwrites existing configuration). + #[arg(long)] + force: bool, + + /// Administrator username (required in non-interactive mode unless --skip-admin is set). + #[arg(long)] + admin_user: Option, + + /// Administrator password (required in non-interactive mode unless --skip-admin is set). + #[arg(long)] + admin_password: Option, + }, + + /// Print configuration and database file paths. + ConfigPath { + /// Output in machine-readable JSON format. + #[arg(long)] + json: bool, + }, + + /// Show details of a user by ID or username. + ShowUser { + /// User ID or username. + id_or_username: String, + + /// Display all granular permissions and roles. + #[arg(long)] + permissions: bool, + }, + + /// Show details of a personal access token by ID. + ShowToken { + /// Token ID. + id: String, + }, + + /// Backup the database to a target path. + Backup { + /// Path where the backup file will be created. + path: PathBuf, + }, +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Securely prompt for a password (no echo). +fn prompt_password(prompt: &str) -> anyhow::Result { + print!("{prompt}"); + io::stdout().flush()?; + + // Use rpassword-style reading without the dep: read from /dev/tty or stdin + // For CLI use, we read a line and trim it (terminals will hide input for + // proper TTY usage; a future version can add rpassword) + let mut pw = String::new(); + io::stdin().read_line(&mut pw)?; + Ok(pw.trim().to_string()) +} + +fn prompt_password_confirmed(prompt: &str, is_admin: bool) -> anyhow::Result { + let pw1 = prompt_password(prompt)?; + let pw2 = prompt_password("Confirm password: ")?; + if pw1 != pw2 { + anyhow::bail!("passwords do not match"); + } + crate::security::passwords::validate_password_strength(&pw1, is_admin) + .map_err(|e| anyhow::anyhow!("{}", e))?; + Ok(pw1) +} + +// ── Command Handlers ────────────────────────────────────────────────────────── + +pub async fn run(command: Commands, config: Config) -> anyhow::Result<()> { + match command { + Commands::Serve => { + // Handled in main.rs — should not be reached here + unreachable!("serve is handled in main") + } + + Commands::Migrate => cmd_migrate(&config).await, + Commands::Doctor => cmd_doctor(&config).await, + + Commands::CreateAdmin { username } => cmd_create_admin(&config, &username).await, + Commands::CreateUser { username } => cmd_create_user(&config, &username).await, + Commands::ListUsers => cmd_list_users(&config).await, + + Commands::DisableUser { id } => cmd_set_status(&config, &id, UserStatus::Disabled).await, + Commands::EnableUser { id } => cmd_set_status(&config, &id, UserStatus::Active).await, + Commands::ResetPassword { id } => cmd_reset_password(&config, &id).await, + + Commands::CreateToken { user, name } => cmd_create_token(&config, &user, &name).await, + Commands::RevokeToken { id } => cmd_revoke_token(&config, &id).await, + + Commands::Init { + non_interactive, + skip_admin, + force, + admin_user, + admin_password, + } => { + cmd_init( + &config, + non_interactive, + skip_admin, + force, + admin_user.as_deref(), + admin_password.as_deref(), + ) + .await + } + Commands::ConfigPath { json } => cmd_config_path(&config, json).await, + Commands::ShowUser { + id_or_username, + permissions, + } => cmd_show_user(&config, &id_or_username, permissions).await, + Commands::ShowToken { id } => cmd_show_token(&config, &id).await, + Commands::Backup { path } => cmd_backup(&config, &path).await, + } +} + +// ── migrate ─────────────────────────────────────────────────────────────────── + +async fn cmd_migrate(config: &Config) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + db::run_migrations(&pool).await?; + println!("✓ Migrations applied successfully."); + Ok(()) +} + +// ── doctor ──────────────────────────────────────────────────────────────────── + +async fn run_doctor_checks(config: &Config) -> anyhow::Result { + let mut ok = true; + + println!("\nnx9-auth doctor\n"); + + // 1. Config loads (already done — we got here with a valid config) + println!(" ✓ Config file loads and parses"); + + // 2. DB path is writable + let db_path = std::path::Path::new(&config.database.path); + let db_dir_writable = if let Some(parent) = db_path.parent() { + if parent.as_os_str().is_empty() { + true + } else if std::fs::create_dir_all(parent).is_err() { + false + } else { + let temp_file = parent.join(format!( + ".nx9_auth_doctor_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + if std::fs::write(&temp_file, b"test").is_ok() { + let _ = std::fs::remove_file(temp_file); + true + } else { + false + } + } + } else { + true + }; + if db_dir_writable { + println!(" ✓ Database directory is writable"); + } else { + println!( + " ✗ Database directory is not writable: {}", + config.database.path + ); + ok = false; + } + + // 3. DB connects + let pool_result = db::create_pool(&config.database.path).await; + let pool = match pool_result { + Ok(p) => { + println!(" ✓ Database connection successful"); + p + } + Err(e) => { + println!(" ✗ Database connection failed: {}", e); + println!("\nDoctor result: FAIL\n"); + return Ok(false); + } + }; + + // 4. Migrations are up to date + // Verify migrations are applied + let migration_check: Result<(i64,), sqlx::Error> = + sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations") + .fetch_one(&pool) + .await; + match migration_check { + Ok((count,)) if count > 0 => println!(" ✓ Migrations applied ({} recorded)", count), + Ok(_) => { + println!(" ✗ No migrations recorded — run `nx9-auth migrate` first"); + ok = false; + } + Err(_) => { + println!(" ✗ Migrations table missing — run `nx9-auth migrate` first"); + ok = false; + } + } + + // 5. Default tenant exists + let tenant_check: Result<(i64,), sqlx::Error> = + sqlx::query_as("SELECT COUNT(*) FROM tenants WHERE id = ?") + .bind(Tenant::DEFAULT_ID) + .fetch_one(&pool) + .await; + match tenant_check { + Ok((1,)) => println!(" ✓ Default tenant exists"), + _ => { + println!(" ✗ Default tenant missing — run `nx9-auth migrate`"); + ok = false; + } + } + + // 6. Admin role exists + match role_repo::admin_role_exists(&pool).await { + Ok(true) => println!(" ✓ admin role exists"), + Ok(false) => { + println!(" ✗ admin role missing — run `nx9-auth migrate`"); + ok = false; + } + Err(e) => { + println!(" ✗ role check failed: {}", e); + ok = false; + } + } + + // 7. At least one admin user exists + match user_repo::count_admins(&pool).await { + Ok(n) if n > 0 => println!(" ✓ {} admin user(s) exist", n), + Ok(_) => { + println!(" ✗ No admin users — run `nx9-auth create-admin `"); + ok = false; + } + Err(e) => { + println!(" ✗ admin count failed: {}", e); + ok = false; + } + } + + // 8. WAL mode + let journal_mode: Result<(String,), sqlx::Error> = + sqlx::query_as("PRAGMA journal_mode").fetch_one(&pool).await; + match journal_mode { + Ok((mode,)) if mode.to_lowercase() == "wal" => println!(" ✓ WAL mode enabled"), + Ok((mode,)) => { + println!(" ✗ WAL mode not enabled (current mode: {})", mode); + ok = false; + } + Err(e) => { + println!(" ✗ Failed to check journal mode: {}", e); + ok = false; + } + } + + // 9. Foreign Keys + let foreign_keys: Result<(i64,), sqlx::Error> = + sqlx::query_as("PRAGMA foreign_keys").fetch_one(&pool).await; + match foreign_keys { + Ok((1,)) => println!(" ✓ Foreign keys constraint enforcement enabled"), + Ok((val,)) => { + println!( + " ✗ Foreign keys constraint enforcement disabled (current value: {})", + val + ); + ok = false; + } + Err(e) => { + println!(" ✗ Failed to check foreign keys: {}", e); + ok = false; + } + } + + // 10. Table existence + for table in &["audit_logs", "sessions"] { + let table_exists: Result, sqlx::Error> = + sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .bind(table) + .fetch_optional(&pool) + .await; + match table_exists { + Ok(Some(_)) => println!(" ✓ Table '{}' exists", table), + Ok(None) => { + println!(" ✗ Table '{}' is missing", table); + ok = false; + } + Err(e) => { + println!(" ✗ Failed to check existence of table '{}': {}", table, e); + ok = false; + } + } + } + + // 11. Database Write Test + let write_test: Result<(), sqlx::Error> = async { + let mut tx = pool.begin().await?; + sqlx::query("CREATE TEMP TABLE doctor_test_write (id INTEGER PRIMARY KEY)") + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO doctor_test_write (id) VALUES (1)") + .execute(&mut *tx) + .await?; + sqlx::query("DROP TABLE doctor_test_write") + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + .await; + match write_test { + Ok(()) => { + println!(" ✓ Database write test successful (temp table creation and deletion)") + } + Err(e) => { + println!(" ✗ Database write test failed: {}", e); + ok = false; + } + } + + // 12. Database Integrity Check + let integrity_check: Result<(String,), sqlx::Error> = sqlx::query_as("PRAGMA integrity_check") + .fetch_one(&pool) + .await; + match integrity_check { + Ok((res,)) if res.to_lowercase() == "ok" => { + println!(" ✓ Database integrity check passed") + } + Ok((res,)) => { + println!(" ✗ Database integrity check failed: {}", res); + ok = false; + } + Err(e) => { + println!(" ✗ Failed to run database integrity check: {}", e); + ok = false; + } + } + + println!(); + if ok { + println!("Doctor result: OK\n"); + } else { + println!("Doctor result: FAIL\n"); + } + + Ok(ok) +} + +async fn cmd_doctor(config: &Config) -> anyhow::Result<()> { + let ok = run_doctor_checks(config).await?; + if !ok { + std::process::exit(1); + } + Ok(()) +} + +// ── create-admin ────────────────────────────────────────────────────────────── + +async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let password = prompt_password_confirmed("Password for admin: ", true)?; + + let user = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + username, + &password, + None, + None, + None, + ) + .await?; + + roles::assign_role(&pool, &user.id, "admin", None, None, None).await?; + + println!("✓ Admin user '{}' created (id: {})", user.username, user.id); + Ok(()) +} + +// ── create-user ─────────────────────────────────────────────────────────────── + +async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let password = prompt_password_confirmed("Password: ", false)?; + + let user = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + username, + &password, + None, + None, + None, + ) + .await?; + + println!("✓ User '{}' created (id: {})", user.username, user.id); + Ok(()) +} + +// ── list-users ──────────────────────────────────────────────────────────────── + +async fn cmd_list_users(config: &Config) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let users = identity_users::list_users(&pool, Tenant::DEFAULT_ID).await?; + + if users.is_empty() { + println!("No users found."); + return Ok(()); + } + + // Table header + println!( + "\n{:<38} {:<24} {:<10} Created", + "ID", "Username", "Status" + ); + println!("{}", "-".repeat(90)); + + for u in &users { + println!( + "{:<38} {:<24} {:<10} {}", + u.id, + u.username, + UserStatus::from_i32(u.status).as_str(), + &u.created_at[..10], + ); + } + println!("\n{} user(s) total\n", users.len()); + Ok(()) +} + +// ── disable/enable-user ─────────────────────────────────────────────────────── + +async fn cmd_set_status(config: &Config, id: &str, status: UserStatus) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let user = identity_users::get_user(&pool, id).await?; + identity_users::update_status(&pool, id, status.as_i32(), None, None, None).await?; + println!( + "✓ User '{}' status set to {}", + user.username, + status.as_str() + ); + Ok(()) +} + +// ── reset-password ──────────────────────────────────────────────────────────── + +async fn cmd_reset_password(config: &Config, id: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let user = identity_users::get_user(&pool, id).await?; + let user_roles = role_repo::list_for_user(&pool, &user.id).await?; + let is_admin = user_roles.iter().any(|r| r.name == "admin"); + let password = + prompt_password_confirmed(&format!("New password for '{}': ", user.username), is_admin)?; + identity_users::reset_password(&pool, &config.security, id, &password, None, None, None) + .await?; + println!("✓ Password reset for user '{}'", user.username); + Ok(()) +} + +// ── create-token ────────────────────────────────────────────────────────────── + +async fn cmd_create_token(config: &Config, user_id: &str, name: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let user = identity_users::get_user(&pool, user_id).await?; + let (token, raw) = + token_security::create_token(&pool, user_id, name, &config.security, None, None, None) + .await?; + + println!( + "\nPersonal Access Token created for user '{}':", + user.username + ); + println!(" Token ID: {}", token.id); + println!(" Name: {}", token.name); + println!( + " Expires at: {}", + token.expires_at.as_deref().unwrap_or("never") + ); + println!(); + println!(" Token: {}", raw); + println!(); + println!("⚠ Store this token securely — it will not be shown again."); + println!(); + Ok(()) +} + +// ── revoke-token ────────────────────────────────────────────────────────────── + +async fn cmd_revoke_token(config: &Config, id: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + + let token = crate::db::repository::tokens::find_by_id(&pool, id) + .await + .map_err(AppError::Database)? + .ok_or_else(|| anyhow::anyhow!("token not found: {}", id))?; + + crate::security::tokens::revoke_token(&pool, id, None, None, None).await?; + + println!("✓ Token '{}' (id: {}) revoked", token.name, token.id); + Ok(()) +} + +// ── init ────────────────────────────────────────────────────────────────────── + +async fn cmd_init( + config: &Config, + non_interactive: bool, + skip_admin: bool, + force: bool, + admin_user: Option<&str>, + admin_password: Option<&str>, +) -> anyhow::Result<()> { + println!("Initializing nx9-auth...\n"); + + // 1. Create config and data and state directories + let config_path = config + .config_path + .clone() + .or_else(Config::default_user_config_path); + if let Some(ref path) = config_path { + println!("Creating configuration directory..."); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + // Write default config if it doesn't exist or if forced + if !path.exists() || force { + let default_content = Config::generate_default_toml(); + std::fs::write(path, default_content)?; + if force && path.exists() { + println!( + "✓ Overwrote config file with default settings at: {}", + path.display() + ); + } else { + println!("✓ Created default config file at: {}", path.display()); + } + } else { + println!("✓ Configuration file already exists at: {}", path.display()); + } + } + + let db_path = std::path::Path::new(&config.database.path); + println!("Creating database directory..."); + if let Some(parent) = db_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + // Create state directory + if let Ok(home) = std::env::var("HOME") { + let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth"); + std::fs::create_dir_all(&state_dir)?; + println!("✓ Created state directory at: {}", state_dir.display()); + } + + // 2. Open DB pool and run migrations + println!("Running migrations..."); + let pool = db::create_pool(&config.database.path).await?; + db::run_migrations(&pool).await?; + println!("✓ Migrations applied successfully."); + + // 3. Create administrator + if skip_admin { + println!("ℹ Administrator creation skipped."); + } else { + let admin_count = user_repo::count_admins(&pool).await?; + if admin_count == 0 { + let username: String; + let password: String; + + if non_interactive { + let u = admin_user.ok_or_else(|| { + anyhow::anyhow!("--admin-user is required in non-interactive mode") + })?; + let p = admin_password.ok_or_else(|| { + anyhow::anyhow!("--admin-password is required in non-interactive mode") + })?; + + // Validate strength + crate::security::passwords::validate_password_strength(p, true) + .map_err(|e| anyhow::anyhow!("Password validation failed: {}", e))?; + + username = u.to_string(); + password = p.to_string(); + } else { + println!("\nCreate administrator:"); + print!("Username [admin]: "); + io::stdout().flush()?; + let mut u_in = String::new(); + io::stdin().read_line(&mut u_in)?; + let u_trimmed = u_in.trim(); + username = if u_trimmed.is_empty() { + "admin".to_string() + } else { + u_trimmed.to_string() + }; + + password = prompt_password_confirmed("Password: ", true)?; + } + + let user = crate::identity::users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + &username, + &password, + None, + None, + None, + ) + .await?; + + roles::assign_role(&pool, &user.id, "admin", None, None, None).await?; + println!("✓ Admin user '{}' created successfully.", username); + } else { + println!("✓ Administrator account already exists."); + } + } + + // 4. Run post-install validation (relaxed) + println!("\nRunning validation..."); + let init_ok = run_init_validation(config, skip_admin).await?; + if !init_ok { + anyhow::bail!("Post-installation validation checks failed!"); + } + + println!("\nnx9-auth is ready.\n\nStart with:\n\n nx9-auth serve\n"); + Ok(()) +} + +async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Result { + let mut ok = true; + + // 1. Config valid + println!(" ✓ Config valid"); + + // 2. Directories writable + let db_path = std::path::Path::new(&config.database.path); + let mut dirs_ok = true; + if let Some(parent) = db_path.parent() { + if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() { + dirs_ok = false; + } + } + if let Ok(home) = std::env::var("HOME") { + let state_dir = std::path::Path::new(&home).join(".local/state/nx9-auth"); + if std::fs::create_dir_all(&state_dir).is_err() { + dirs_ok = false; + } + } + if dirs_ok { + println!(" ✓ Directories writable"); + } else { + println!(" ✗ Directories not writable"); + ok = false; + } + + // 3. Database reachable + let pool = match db::create_pool(&config.database.path).await { + Ok(p) => { + println!(" ✓ Database reachable"); + p + } + Err(e) => { + println!(" ✗ Database connection failed: {}", e); + return Ok(false); + } + }; + + // 4. Migrations applied + let migration_check: Result<(i64,), sqlx::Error> = + sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations") + .fetch_one(&pool) + .await; + match migration_check { + Ok((count,)) if count > 0 => println!(" ✓ Migrations applied"), + _ => { + println!(" ✗ Migrations not applied"); + ok = false; + } + } + + // 5. Admin account check + let admin_count = user_repo::count_admins(&pool).await.unwrap_or(0); + if admin_count > 0 { + println!(" ✓ Administrator account exists"); + } else if admin_skipped { + println!(" ℹ Administrator creation skipped"); + } else { + println!(" ✗ No administrator account exists"); + ok = false; + } + + Ok(ok) +} + +// ── config-path ────────────────────────────────────────────────────────────── + +async fn cmd_config_path(config: &Config, json: bool) -> anyhow::Result<()> { + let config_file = config + .config_path + .clone() + .or_else(Config::default_user_config_path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let database_file = config.database.path.clone(); + + let state_dir = if let Ok(home) = std::env::var("HOME") { + std::path::Path::new(&home) + .join(".local/state/nx9-auth") + .to_string_lossy() + .into_owned() + } else { + "".to_string() + }; + + if json { + let val = serde_json::json!({ + "config": config_file, + "database": database_file, + "state": state_dir, + }); + println!("{}", serde_json::to_string_pretty(&val)?); + } else { + println!("\nConfig:"); + println!(" {}", config_file); + println!("\nDatabase:"); + println!(" {}", database_file); + println!("\nLogs/State:"); + println!(" {}", state_dir); + println!(); + } + Ok(()) +} + +// ── show-user ───────────────────────────────────────────────────────────────── + +async fn cmd_show_user( + config: &Config, + id_or_username: &str, + permissions: bool, +) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + + let user = match user_repo::find_by_id(&pool, id_or_username).await? { + Some(u) => Some(u), + None => user_repo::find_by_username(&pool, id_or_username).await?, + }; + + let user = match user { + Some(u) => u, + None => anyhow::bail!("User not found: '{}'", id_or_username), + }; + + let user_roles = role_repo::list_for_user(&pool, &user.id).await?; + let role_names: Vec = user_roles.into_iter().map(|r| r.name).collect(); + + println!("\nUser"); + println!("────"); + println!("ID: {}", user.id); + println!("Username: {}", user.username); + println!("Status: {}", user.status().as_str()); + println!("Created: {}", user.created_at); + println!( + "Last Login: {}", + user.last_login_at.as_deref().unwrap_or("never") + ); + + println!("\nRoles"); + println!("─────"); + if role_names.is_empty() { + println!("none"); + } else { + for role in &role_names { + println!("{}", role); + } + } + + if permissions { + println!("\nPermissions"); + println!("───────────"); + + let user_perms = crate::db::repository::permissions::list_for_user(&pool, &user.id).await?; + if user_perms.is_empty() { + println!("none"); + } else { + for perm in user_perms { + println!("{}", perm); + } + } + } + println!(); + + Ok(()) +} + +// ── show-token ──────────────────────────────────────────────────────────────── + +async fn cmd_show_token(config: &Config, id: &str) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database.path).await?; + let token = crate::db::repository::tokens::find_by_id(&pool, id) + .await + .map_err(AppError::Database)? + .ok_or_else(|| anyhow::anyhow!("Token not found: {}", id))?; + + let user = user_repo::find_by_id(&pool, &token.user_id).await?; + let username = user + .map(|u| u.username) + .unwrap_or_else(|| "unknown".to_string()); + + println!("\nToken"); + println!("─────"); + println!("ID: {}", token.id); + println!("Name: {}", token.name); + println!("User ID: {}", token.user_id); + println!("Username: {}", username); + println!( + "Status: {}", + if token.revoked { "revoked" } else { "active" } + ); + println!( + "Expires: {}", + token.expires_at.as_deref().unwrap_or("never") + ); + println!( + "Last Used: {}", + token.last_used_at.as_deref().unwrap_or("never") + ); + println!("Created: {}", token.created_at); + println!(); + + Ok(()) +} + +// ── backup ──────────────────────────────────────────────────────────────────── + +async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<()> { + // 1. Resolve paths to absolute paths + let source_path = std::path::Path::new(&config.database.path); + + let abs_source = + std::fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf()); + + let abs_target = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + let source_dir = abs_source.parent().unwrap(); + let source_file_name = abs_source.file_name().unwrap().to_string_lossy(); + let source_wal = source_dir.join(format!("{}-wal", source_file_name)); + let source_shm = source_dir.join(format!("{}-shm", source_file_name)); + + if abs_target == abs_source { + anyhow::bail!( + "Backup destination cannot be the active database file: {}", + path.display() + ); + } + if abs_target == source_wal { + anyhow::bail!( + "Backup destination cannot be the active WAL file: {}", + path.display() + ); + } + if abs_target == source_shm { + anyhow::bail!( + "Backup destination cannot be the active SHM file: {}", + path.display() + ); + } + + // 2. Ensure parent directory exists + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + + // 3. Delete target file if it already exists to overwrite + if path.exists() { + std::fs::remove_file(path)?; + } + + // 4. Perform SQLite VACUUM INTO + // VACUUM INTO is a standard SQL statement supported by SQLite + // for transactionally consistent online backups. It is the modern + // SQL alternative to the online backup C API, especially on WAL-enabled databases. + let pool = db::create_pool(&config.database.path).await?; + let path_str = path.to_string_lossy().replace('\'', "''"); + let query = format!("VACUUM INTO '{}'", path_str); + + sqlx::query(sqlx::AssertSqlSafe(query)) + .execute(&pool) + .await?; + + println!( + "✓ Database backup created successfully at: {}", + path.display() + ); + Ok(()) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..fa6b5d9 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,272 @@ +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// Root configuration loaded from config.toml +#[derive(Debug, Deserialize, Clone, Default)] +pub struct Config { + #[serde(skip)] + pub config_path: Option, + + #[serde(default)] + pub server: ServerConfig, + + #[serde(default)] + pub database: DatabaseConfig, + + #[serde(default)] + pub security: SecurityConfig, + + #[serde(default)] + pub audit: AuditConfig, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct ServerConfig { + /// Interface to listen on. + pub host: String, + /// Port to listen on. + pub port: u16, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct DatabaseConfig { + /// Path to the SQLite database file (supports ~ prefix). + pub path: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct SecurityConfig { + /// Session idle timeout in hours. + pub session_ttl_hours: u32, + /// Session absolute lifetime in days. + pub session_absolute_ttl_days: u32, + /// Default API token lifetime in days. + pub token_ttl_days: u32, + /// Argon2id memory cost (KiB). + pub argon2_memory: u32, + /// Argon2id iteration count. + pub argon2_iterations: u32, + /// Argon2id parallelism. + pub argon2_parallelism: u32, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AuditConfig { + /// Whether to write events to the audit_logs table. + pub enabled: bool, +} + +// ── Defaults ──────────────────────────────────────────────────────────────── + +impl Default for ServerConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), // Default to loopback for user mode safety + port: 8655, + } + } +} + +impl Default for DatabaseConfig { + fn default() -> Self { + let default_db_path = if let Ok(home) = std::env::var("HOME") { + Path::new(&home) + .join(".local/share/nx9-auth/auth.db") + .to_string_lossy() + .into_owned() + } else { + "/var/lib/nx9-auth/auth.db".to_string() + }; + Self { + path: default_db_path, + } + } +} + +impl Default for SecurityConfig { + fn default() -> Self { + Self { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 65536, + argon2_iterations: 3, + argon2_parallelism: 1, + } + } +} + +impl Default for AuditConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn resolve_home_path(path: &str) -> String { + if let Some(stripped) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return Path::new(&home) + .join(stripped) + .to_string_lossy() + .into_owned(); + } + } + path.to_string() +} + +// ── Loading ────────────────────────────────────────────────────────────────── + +impl Config { + /// Resolve path prefixes such as ~ to actual home directories. + pub fn resolve_paths(&mut self) { + self.database.path = resolve_home_path(&self.database.path); + } + + /// Load and parse config from a TOML file. + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read config file: {}", path.display()))?; + let mut config: Config = toml::from_str(&content) + .with_context(|| format!("failed to parse config file: {}", path.display()))?; + config.config_path = Some(path.to_path_buf()); + config.resolve_paths(); + Ok(config) + } + + /// Load config, falling back to defaults if the file doesn't exist. + /// Errors on malformed files. + pub fn load_or_default(path: &Path) -> Result { + let mut config = if path.exists() { + Self::load(path)? + } else { + let mut cfg = Self::default(); + cfg.resolve_paths(); + cfg + }; + config.config_path = Some(path.to_path_buf()); + Ok(config) + } + + /// Canonical config path candidates in priority order: + /// 1. ./config.toml (Current directory override) + /// 2. $XDG_CONFIG_HOME/nx9-auth/config.toml or ~/.config/nx9-auth/config.toml + /// 3. /etc/nx9-auth/config.toml (System-wide default) + pub fn search_paths() -> Vec { + let mut paths = Vec::new(); + + // 1. Current directory override + paths.push(PathBuf::from("./config.toml")); + + // 2. ~/.config/nx9-auth/config.toml (or XDG_CONFIG_HOME) + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + paths.push(PathBuf::from(xdg).join("nx9-auth/config.toml")); + } + } else if let Ok(home) = std::env::var("HOME") { + paths.push(PathBuf::from(home).join(".config/nx9-auth/config.toml")); + } + + // 3. System-wide default + paths.push(PathBuf::from("/etc/nx9-auth/config.toml")); + + paths + } + + /// Default user configuration path (~/.config/nx9-auth/config.toml) + pub fn default_user_config_path() -> Option { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return Some(PathBuf::from(xdg).join("nx9-auth/config.toml")); + } + } + if let Ok(home) = std::env::var("HOME") { + return Some(PathBuf::from(home).join(".config/nx9-auth/config.toml")); + } + None + } + + /// Find and load the first existing config file from the search path. + /// Returns Ok(None) if no configuration file is found in any search path. + pub fn find_and_load(override_path: Option<&Path>) -> Result> { + if let Some(p) = override_path { + let mut config = Self::load(p)?; + config.config_path = Some(p.to_path_buf()); + return Ok(Some(config)); + } + for path in Self::search_paths() { + if path.exists() { + let mut config = Self::load(&path)?; + config.config_path = Some(path); + return Ok(Some(config)); + } + } + Ok(None) + } + + /// Generate default TOML content for the `init` command + pub fn generate_default_toml() -> &'static str { + r#"# nx9-auth configuration file + +[server] +# Interface to bind on. Use 127.0.0.1 for local/user mode. +host = "127.0.0.1" +port = 8655 + +[database] +# Absolute or home-relative path to the SQLite database file. +path = "~/.local/share/nx9-auth/auth.db" + +[security] +# Session idle timeout in hours. +session_ttl_hours = 24 +# Session absolute lifetime in days. +session_absolute_ttl_days = 30 +# Default API token lifetime in days. +token_ttl_days = 365 + +# Argon2id verification parameters (production strength recommended). +argon2_memory = 65536 +argon2_iterations = 3 +argon2_parallelism = 1 + +[audit] +# Enable structured audit logging to the database. +enabled = true +"# + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + let cfg = Config::default(); + assert_eq!(cfg.server.port, 8655); + assert_eq!(cfg.server.host, "127.0.0.1"); + if std::env::var("HOME").is_ok() { + assert!(cfg.database.path.contains(".local/share/nx9-auth/auth.db")); + } else { + assert_eq!(cfg.database.path, "/var/lib/nx9-auth/auth.db"); + } + assert_eq!(cfg.security.session_ttl_hours, 24); + assert_eq!(cfg.security.session_absolute_ttl_days, 30); + assert_eq!(cfg.security.token_ttl_days, 365); + assert!(cfg.audit.enabled); + } + + #[test] + fn test_search_paths() { + let paths = Config::search_paths(); + assert!(paths.iter().any(|p| p.to_str().unwrap() == "./config.toml")); + assert!( + paths + .iter() + .any(|p| p.to_str().unwrap() == "/etc/nx9-auth/config.toml") + ); + } +} diff --git a/src/db/migrations/0001_create_tenants.sql b/src/db/migrations/0001_create_tenants.sql new file mode 100644 index 0000000..462c1e7 --- /dev/null +++ b/src/db/migrations/0001_create_tenants.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS tenants ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug); diff --git a/src/db/migrations/0002_create_users.sql b/src/db/migrations/0002_create_users.sql new file mode 100644 index 0000000..cd59f78 --- /dev/null +++ b/src/db/migrations/0002_create_users.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + -- 1 = active, 2 = disabled, 3 = locked + status INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE (tenant_id, username) +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); diff --git a/src/db/migrations/0003_create_user_profiles.sql b/src/db/migrations/0003_create_user_profiles.sql new file mode 100644 index 0000000..4cb04ab --- /dev/null +++ b/src/db/migrations/0003_create_user_profiles.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT, + full_name TEXT, + avatar_url TEXT, + metadata_json TEXT +); diff --git a/src/db/migrations/0004_create_roles.sql b/src/db/migrations/0004_create_roles.sql new file mode 100644 index 0000000..8ba2220 --- /dev/null +++ b/src/db/migrations/0004_create_roles.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS roles ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT +); diff --git a/src/db/migrations/0005_create_permissions.sql b/src/db/migrations/0005_create_permissions.sql new file mode 100644 index 0000000..216613c --- /dev/null +++ b/src/db/migrations/0005_create_permissions.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS permissions ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT +); diff --git a/src/db/migrations/0006_create_role_permissions.sql b/src/db/migrations/0006_create_role_permissions.sql new file mode 100644 index 0000000..a0eb2e4 --- /dev/null +++ b/src/db/migrations/0006_create_role_permissions.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role_id); diff --git a/src/db/migrations/0007_create_user_roles.sql b/src/db/migrations/0007_create_user_roles.sql new file mode 100644 index 0000000..8d8a27c --- /dev/null +++ b/src/db/migrations/0007_create_user_roles.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id); diff --git a/src/db/migrations/0008_create_sessions.sql b/src/db/migrations/0008_create_sessions.sql new file mode 100644 index 0000000..2384654 --- /dev/null +++ b/src/db/migrations/0008_create_sessions.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); diff --git a/src/db/migrations/0009_create_api_tokens.sql b/src/db/migrations/0009_create_api_tokens.sql new file mode 100644 index 0000000..56af490 --- /dev/null +++ b/src/db/migrations/0009_create_api_tokens.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + last_used_at TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash); diff --git a/src/db/migrations/0010_create_service_accounts.sql b/src/db/migrations/0010_create_service_accounts.sql new file mode 100644 index 0000000..e4b3dce --- /dev/null +++ b/src/db/migrations/0010_create_service_accounts.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS service_accounts ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + description TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE (tenant_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON service_accounts(tenant_id); diff --git a/src/db/migrations/0011_create_applications.sql b/src/db/migrations/0011_create_applications.sql new file mode 100644 index 0000000..9ad714b --- /dev/null +++ b/src/db/migrations/0011_create_applications.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_applications_tenant ON applications(tenant_id); +CREATE INDEX IF NOT EXISTS idx_applications_slug ON applications(slug); diff --git a/src/db/migrations/0012_create_audit_logs.sql b/src/db/migrations/0012_create_audit_logs.sql new file mode 100644 index 0000000..978fa2e --- /dev/null +++ b/src/db/migrations/0012_create_audit_logs.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY NOT NULL, + actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + target_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + -- 'info', 'warning', 'critical' + severity TEXT NOT NULL DEFAULT 'info', + ip_address TEXT, + user_agent TEXT, + metadata_json TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_severity ON audit_logs(severity); diff --git a/src/db/migrations/0013_seed_default_tenant.sql b/src/db/migrations/0013_seed_default_tenant.sql new file mode 100644 index 0000000..9ed45a6 --- /dev/null +++ b/src/db/migrations/0013_seed_default_tenant.sql @@ -0,0 +1,4 @@ +-- Seed the default tenant. +-- Uses INSERT OR IGNORE so re-running migrations is safe. +INSERT OR IGNORE INTO tenants (id, name, slug, enabled) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1); diff --git a/src/db/migrations/0014_seed_roles_and_permissions.sql b/src/db/migrations/0014_seed_roles_and_permissions.sql new file mode 100644 index 0000000..deefb1e --- /dev/null +++ b/src/db/migrations/0014_seed_roles_and_permissions.sql @@ -0,0 +1,35 @@ +-- ── Roles ──────────────────────────────────────────────────────────────────── + +INSERT OR IGNORE INTO roles (id, name, description) VALUES + ('10000000-0000-0000-0000-000000000001', 'admin', 'Full system access'), + ('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'), + ('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access'); + +-- ── Permissions ─────────────────────────────────────────────────────────────── + +INSERT OR IGNORE INTO permissions (id, name, description) VALUES + ('20000000-0000-0000-0000-000000000001', 'users:create', 'Create new user accounts'), + ('20000000-0000-0000-0000-000000000002', 'users:update', 'Update user accounts'), + ('20000000-0000-0000-0000-000000000003', 'users:delete', 'Disable user accounts'), + ('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'), + ('20000000-0000-0000-0000-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'), + ('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'), + ('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries'); + +-- ── Admin role gets all permissions ────────────────────────────────────────── + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions; + +-- ── Editor role permissions ─────────────────────────────────────────────────── + +INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES + ('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001'), + ('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002'); + +-- ── Default applications ────────────────────────────────────────────────────── + +INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES + ('30000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'BZOD', 'bzod', 1), + ('30000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'ChronoSeal', 'chronoseal', 1), + ('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1); diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..850651a --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,68 @@ +use anyhow::{Context, Result}; +use sqlx::{SqlitePool, sqlite::SqlitePoolOptions}; + +/// Create and configure the SQLite connection pool. +/// +/// Enables WAL mode, foreign keys, and a busy timeout so concurrent writers +/// do not immediately error — they back off and retry for up to 5 seconds. +pub async fn create_pool(path: &str) -> Result { + // Ensure the parent directory exists + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create database directory: {}", parent.display()) + })?; + } + } + + let url = format!("sqlite://{}?mode=rwc", path); + + let pool = SqlitePoolOptions::new() + .max_connections(16) + .min_connections(1) + .connect(&url) + .await + .with_context(|| format!("failed to open database: {path}"))?; + + // Apply foundational PRAGMAs on every connection + sqlx::query("PRAGMA journal_mode = WAL") + .execute(&pool) + .await + .context("PRAGMA journal_mode")?; + + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&pool) + .await + .context("PRAGMA foreign_keys")?; + + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(&pool) + .await + .context("PRAGMA busy_timeout")?; + + sqlx::query("PRAGMA synchronous = NORMAL") + .execute(&pool) + .await + .context("PRAGMA synchronous")?; + + sqlx::query("PRAGMA cache_size = -32768") // 32 MiB page cache + .execute(&pool) + .await + .context("PRAGMA cache_size")?; + + tracing::info!(path = path, "database pool opened"); + Ok(pool) +} + +/// Run all pending SQLx migrations embedded in `src/db/migrations/`. +pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { + sqlx::migrate!("src/db/migrations") + .run(pool) + .await + .context("failed to run database migrations")?; + tracing::info!("database migrations applied"); + Ok(()) +} + +pub mod models; +pub mod repository; diff --git a/src/db/models/api_token.rs b/src/db/models/api_token.rs new file mode 100644 index 0000000..969d733 --- /dev/null +++ b/src/db/models/api_token.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// A personal access token row from the `api_tokens` table. +/// +/// `token_hash` is the BLAKE3 hex-encoded hash of the raw `nx9_pat_...` token. +/// The raw token is displayed exactly once at creation time and never stored. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ApiToken { + pub id: String, + pub user_id: String, + pub name: String, + /// BLAKE3 hex hash — never expose in API responses. + #[serde(skip_serializing)] + pub token_hash: String, + pub last_used_at: Option, + pub expires_at: Option, + pub created_at: String, + pub revoked: bool, +} diff --git a/src/db/models/application.rs b/src/db/models/application.rs new file mode 100644 index 0000000..5ad3efe --- /dev/null +++ b/src/db/models/application.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Application { + pub id: String, + pub tenant_id: String, + pub name: String, + pub slug: String, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/db/models/audit_log.rs b/src/db/models/audit_log.rs new file mode 100644 index 0000000..92f167a --- /dev/null +++ b/src/db/models/audit_log.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// Audit event severity level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuditSeverity { + Info, + Warning, + Critical, +} + +impl AuditSeverity { + pub fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Critical => "critical", + } + } +} + +impl std::str::FromStr for AuditSeverity { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + match s { + "warning" => Ok(Self::Warning), + "critical" => Ok(Self::Critical), + _ => Ok(Self::Info), + } + } +} + +impl std::fmt::Display for AuditSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A row from the `audit_logs` table. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct AuditLog { + pub id: String, + pub actor_user_id: Option, + pub target_user_id: Option, + pub action: String, + pub resource_type: String, + pub resource_id: Option, + pub severity: String, + pub ip_address: Option, + pub user_agent: Option, + pub metadata_json: Option, + pub created_at: String, +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs new file mode 100644 index 0000000..bf0464b --- /dev/null +++ b/src/db/models/mod.rs @@ -0,0 +1,20 @@ +pub mod api_token; +pub mod application; +pub mod audit_log; +pub mod permission; +pub mod role; +pub mod service_account; +pub mod session; +pub mod tenant; +pub mod user; + +pub use api_token::ApiToken; +pub use application::Application; +pub use audit_log::{AuditLog, AuditSeverity}; +#[allow(unused_imports)] +pub use permission::Permission; +pub use role::Role; +pub use service_account::ServiceAccount; +pub use session::Session; +pub use tenant::Tenant; +pub use user::{User, UserStatus}; diff --git a/src/db/models/permission.rs b/src/db/models/permission.rs new file mode 100644 index 0000000..15a3414 --- /dev/null +++ b/src/db/models/permission.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Permission { + pub id: String, + pub name: String, + pub description: Option, +} diff --git a/src/db/models/role.rs b/src/db/models/role.rs new file mode 100644 index 0000000..2b2a23f --- /dev/null +++ b/src/db/models/role.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Role { + pub id: String, + pub name: String, + pub description: Option, +} diff --git a/src/db/models/service_account.rs b/src/db/models/service_account.rs new file mode 100644 index 0000000..855f74e --- /dev/null +++ b/src/db/models/service_account.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ServiceAccount { + pub id: String, + pub tenant_id: String, + pub name: String, + pub description: Option, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/db/models/session.rs b/src/db/models/session.rs new file mode 100644 index 0000000..f16ce42 --- /dev/null +++ b/src/db/models/session.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// A session row from the `sessions` table. +/// +/// `token_hash` is the BLAKE3 hex-encoded hash of the raw session token. +/// The raw token is stored in a cookie and never persisted. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Session { + pub id: String, + pub user_id: String, + /// BLAKE3 hex hash of the raw cookie value. + #[serde(skip_serializing)] + pub token_hash: String, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: String, + /// Absolute expiry — the session is dead after this regardless of activity. + pub expires_at: String, + /// Idle timeout — updated on each authenticated request. + pub last_seen_at: String, + pub revoked: bool, +} diff --git a/src/db/models/tenant.rs b/src/db/models/tenant.rs new file mode 100644 index 0000000..3cd9a11 --- /dev/null +++ b/src/db/models/tenant.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Tenant { + pub id: String, + pub name: String, + pub slug: String, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +impl Tenant { + pub const DEFAULT_ID: &'static str = "00000000-0000-0000-0000-000000000001"; + pub const DEFAULT_SLUG: &'static str = "default"; +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs new file mode 100644 index 0000000..92c7259 --- /dev/null +++ b/src/db/models/user.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// User account status. +/// +/// Stored as INTEGER in SQLite: 1 = Active, 2 = Disabled, 3 = Locked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UserStatus { + Active = 1, + Disabled = 2, + Locked = 3, +} + +impl UserStatus { + pub fn from_i32(v: i32) -> Self { + match v { + 2 => Self::Disabled, + 3 => Self::Locked, + _ => Self::Active, + } + } + + pub fn as_i32(self) -> i32 { + self as i32 + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Disabled => "disabled", + Self::Locked => "locked", + } + } +} + +impl std::fmt::Display for UserStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A user account row from the `users` table. +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct User { + pub id: String, + pub tenant_id: String, + pub username: String, + /// Argon2id PHC string — never expose in API responses. + #[serde(skip_serializing)] + pub password_hash: String, + /// Raw integer status — use `status()` for the typed enum. + pub status: i32, + pub last_login_at: Option, + pub created_at: String, + pub updated_at: String, +} + +impl User { + /// Typed status accessor. + pub fn status(&self) -> UserStatus { + UserStatus::from_i32(self.status) + } + + pub fn is_active(&self) -> bool { + self.status() == UserStatus::Active + } +} diff --git a/src/db/repository/applications.rs b/src/db/repository/applications.rs new file mode 100644 index 0000000..1860a58 --- /dev/null +++ b/src/db/repository/applications.rs @@ -0,0 +1,60 @@ +use sqlx::SqlitePool; + +use crate::db::models::Application; + +pub async fn create( + pool: &SqlitePool, + id: &str, + tenant_id: &str, + name: &str, + slug: &str, +) -> Result { + sqlx::query_as::<_, Application>( + r#" + INSERT INTO applications (id, tenant_id, name, slug) + VALUES (?, ?, ?, ?) + RETURNING * + "#, + ) + .bind(id) + .bind(tenant_id) + .bind(name) + .bind(slug) + .fetch_one(pool) + .await +} + +pub async fn find_by_slug( + pool: &SqlitePool, + slug: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE slug = ?") + .bind(slug) + .fetch_optional(pool) + .await +} + +pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE tenant_id = ? ORDER BY name") + .bind(tenant_id) + .fetch_all(pool) + .await +} + +pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(enabled) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/db/repository/audit.rs b/src/db/repository/audit.rs new file mode 100644 index 0000000..a7ad0ef --- /dev/null +++ b/src/db/repository/audit.rs @@ -0,0 +1,49 @@ +use sqlx::SqlitePool; + +use crate::db::models::AuditLog; + +#[allow(clippy::too_many_arguments)] +pub async fn insert( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + actor_user_id: Option<&str>, + target_user_id: Option<&str>, + action: &str, + resource_type: &str, + resource_id: Option<&str>, + severity: &str, + ip_address: Option<&str>, + user_agent: Option<&str>, + metadata_json: Option<&str>, +) -> Result { + sqlx::query_as::<_, AuditLog>( + r#" + INSERT INTO audit_logs ( + id, actor_user_id, target_user_id, + action, resource_type, resource_id, + severity, ip_address, user_agent, metadata_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING * + "#, + ) + .bind(id) + .bind(actor_user_id) + .bind(target_user_id) + .bind(action) + .bind(resource_type) + .bind(resource_id) + .bind(severity) + .bind(ip_address) + .bind(user_agent) + .bind(metadata_json) + .fetch_one(&mut **tx) + .await +} + +pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?") + .bind(limit) + .fetch_all(pool) + .await +} diff --git a/src/db/repository/mod.rs b/src/db/repository/mod.rs new file mode 100644 index 0000000..5af8e20 --- /dev/null +++ b/src/db/repository/mod.rs @@ -0,0 +1,8 @@ +pub mod applications; +pub mod audit; +pub mod permissions; +pub mod roles; +pub mod service_accounts; +pub mod sessions; +pub mod tokens; +pub mod users; diff --git a/src/db/repository/permissions.rs b/src/db/repository/permissions.rs new file mode 100644 index 0000000..eb26008 --- /dev/null +++ b/src/db/repository/permissions.rs @@ -0,0 +1,41 @@ +use sqlx::SqlitePool; + +/// Return all permission names held by a user (via their roles). +pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as( + r#" + SELECT DISTINCT p.name + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + WHERE ur.user_id = ? + ORDER BY p.name + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(name,)| name).collect()) +} + +/// Check if a user holds a specific named permission. +pub async fn user_has_permission( + pool: &SqlitePool, + user_id: &str, + permission_name: &str, +) -> Result { + let row: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) + FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + WHERE ur.user_id = ? AND p.name = ? + "#, + ) + .bind(user_id) + .bind(permission_name) + .fetch_one(pool) + .await?; + Ok(row.0 > 0) +} diff --git a/src/db/repository/roles.rs b/src/db/repository/roles.rs new file mode 100644 index 0000000..cda38f1 --- /dev/null +++ b/src/db/repository/roles.rs @@ -0,0 +1,70 @@ +use sqlx::SqlitePool; + +use crate::db::models::Role; + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name") + .fetch_all(pool) + .await +} + +pub async fn find_by_name(pool: &SqlitePool, name: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = ?") + .bind(name) + .fetch_optional(pool) + .await +} + +pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Role>( + r#" + SELECT r.* FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = ? + ORDER BY r.name + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn assign_to_user( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + user_id: &str, + role_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)") + .bind(user_id) + .bind(role_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn remove_from_user( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + user_id: &str, + role_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?") + .bind(user_id) + .bind(role_id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn admin_role_exists(pool: &SqlitePool) -> Result { + let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'") + .fetch_one(pool) + .await?; + Ok(row.0 > 0) +} diff --git a/src/db/repository/service_accounts.rs b/src/db/repository/service_accounts.rs new file mode 100644 index 0000000..349e4a0 --- /dev/null +++ b/src/db/repository/service_accounts.rs @@ -0,0 +1,59 @@ +use sqlx::SqlitePool; + +use crate::db::models::ServiceAccount; + +pub async fn create( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + tenant_id: &str, + name: &str, + description: Option<&str>, +) -> Result { + sqlx::query_as::<_, ServiceAccount>( + r#" + INSERT INTO service_accounts (id, tenant_id, name, description) + VALUES (?, ?, ?, ?) + RETURNING * + "#, + ) + .bind(id) + .bind(tenant_id) + .bind(name) + .bind(description) + .fetch_one(&mut **tx) + .await +} + +pub async fn find_by_id( + pool: &SqlitePool, + id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, ServiceAccount>( + "SELECT * FROM service_accounts WHERE tenant_id = ? ORDER BY name", + ) + .bind(tenant_id) + .fetch_all(pool) + .await +} + +pub async fn set_enabled( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + enabled: bool, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE service_accounts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(enabled) + .bind(id) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/src/db/repository/sessions.rs b/src/db/repository/sessions.rs new file mode 100644 index 0000000..3c441e6 --- /dev/null +++ b/src/db/repository/sessions.rs @@ -0,0 +1,79 @@ +use sqlx::SqlitePool; + +use crate::db::models::Session; + +pub async fn create( + pool: &SqlitePool, + id: &str, + user_id: &str, + token_hash: &str, + ip_address: Option<&str>, + user_agent: Option<&str>, + expires_at: &str, +) -> Result { + sqlx::query_as::<_, Session>( + r#" + INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING * + "#, + ) + .bind(id) + .bind(user_id) + .bind(token_hash) + .bind(ip_address) + .bind(user_agent) + .bind(expires_at) + .fetch_one(pool) + .await +} + +pub async fn find_by_token_hash( + pool: &SqlitePool, + token_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0") + .bind(token_hash) + .fetch_optional(pool) + .await +} + +pub async fn revoke(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn revoke_all_for_user(pool: &SqlitePool, user_id: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = ?") + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn update_last_seen(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +/// Delete sessions that are expired or revoked. Called once at startup. +pub async fn cleanup_expired(pool: &SqlitePool) -> Result { + let result = sqlx::query( + r#" + DELETE FROM sessions + WHERE revoked = 1 + OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + "#, + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/src/db/repository/tokens.rs b/src/db/repository/tokens.rs new file mode 100644 index 0000000..bcd9ad5 --- /dev/null +++ b/src/db/repository/tokens.rs @@ -0,0 +1,74 @@ +use sqlx::SqlitePool; + +use crate::db::models::ApiToken; + +pub async fn create( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + user_id: &str, + name: &str, + token_hash: &str, + expires_at: Option<&str>, +) -> Result { + sqlx::query_as::<_, ApiToken>( + r#" + INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at) + VALUES (?, ?, ?, ?, ?) + RETURNING * + "#, + ) + .bind(id) + .bind(user_id) + .bind(name) + .bind(token_hash) + .bind(expires_at) + .fetch_one(&mut **tx) + .await +} + +pub async fn find_by_hash( + pool: &SqlitePool, + token_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked = 0") + .bind(token_hash) + .fetch_optional(pool) + .await +} + +pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiToken>( + "SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC", + ) + .bind(user_id) + .fetch_all(pool) + .await +} + +pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn revoke( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = ?") + .bind(id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn update_last_used(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} diff --git a/src/db/repository/users.rs b/src/db/repository/users.rs new file mode 100644 index 0000000..7d44e8a --- /dev/null +++ b/src/db/repository/users.rs @@ -0,0 +1,121 @@ +use sqlx::SqlitePool; + +use crate::db::models::User; + +pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await +} + +pub async fn find_by_username( + pool: &SqlitePool, + username: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?") + .bind(username) + .fetch_optional(pool) + .await +} + +pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC") + .bind(tenant_id) + .fetch_all(pool) + .await +} + +pub async fn create( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + tenant_id: &str, + username: &str, + password_hash: &str, +) -> Result { + sqlx::query_as::<_, User>( + r#" + INSERT INTO users (id, tenant_id, username, password_hash, status) + VALUES (?, ?, ?, ?, 1) + RETURNING * + "#, + ) + .bind(id) + .bind(tenant_id) + .bind(username) + .bind(password_hash) + .fetch_one(&mut **tx) + .await +} + +pub async fn update_status( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + status: i32, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(status) + .bind(id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn update_password_hash( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, + password_hash: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(password_hash) + .bind(id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn set_last_login( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?", + ) + .bind(id) + .execute(&mut **tx) + .await?; + Ok(()) +} + +pub async fn username_exists( + pool: &SqlitePool, + tenant_id: &str, + username: &str, +) -> Result { + let row: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND username = ?") + .bind(tenant_id) + .bind(username) + .fetch_one(pool) + .await?; + Ok(row.0 > 0) +} + +/// Count users that have the admin role. +pub async fn count_admins(pool: &SqlitePool) -> Result { + let row: (i64,) = sqlx::query_as( + r#" + SELECT COUNT(DISTINCT ur.user_id) + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE r.name = 'admin' + "#, + ) + .fetch_one(pool) + .await?; + Ok(row.0) +} diff --git a/src/error/mod.rs b/src/error/mod.rs new file mode 100644 index 0000000..39b539d --- /dev/null +++ b/src/error/mod.rs @@ -0,0 +1,104 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; +use thiserror::Error; + +/// Central application error type. +/// All handlers return `Result`, which Axum maps to HTTP responses. +#[derive(Debug, Error)] +pub enum AppError { + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("resource not found")] + NotFound, + + #[error("invalid credentials")] + Unauthorized, + + #[error("insufficient permissions")] + Forbidden, + + #[error("conflict: {0}")] + Conflict(String), + + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("too many requests")] + RateLimited, + + #[error("internal error")] + Internal, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, code) = match &self { + AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), + AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"), + AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"), + AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"), + AppError::InvalidInput(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_input"), + AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), + AppError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), + }; + + // Log server-side errors for visibility + match &self { + AppError::Database(e) => { + tracing::error!(error = %e, "database error"); + } + AppError::Internal => { + tracing::error!("internal error"); + } + _ => {} + } + + let body = json!({ + "error": self.to_string(), + "code": code, + }); + + (status, Json(body)).into_response() + } +} + +/// Convenience alias used throughout the codebase. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_status_mapping() { + let err_not_found = AppError::NotFound; + let resp = err_not_found.into_response(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + let err_unauthorized = AppError::Unauthorized; + let resp = err_unauthorized.into_response(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let err_forbidden = AppError::Forbidden; + let resp = err_forbidden.into_response(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let err_conflict = AppError::Conflict("already exists".into()); + let resp = err_conflict.into_response(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + let err_invalid = AppError::InvalidInput("bad value".into()); + let resp = err_invalid.into_response(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let err_rate = AppError::RateLimited; + let resp = err_rate.into_response(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + } +} diff --git a/src/identity/applications.rs b/src/identity/applications.rs new file mode 100644 index 0000000..5d0864e --- /dev/null +++ b/src/identity/applications.rs @@ -0,0 +1,31 @@ +use sqlx::SqlitePool; + +use crate::{ + db::{models::Application, repository::applications as repo}, + error::AppError, +}; + +pub async fn create( + pool: &SqlitePool, + tenant_id: &str, + name: &str, + slug: &str, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + repo::create(pool, &id, tenant_id, name, slug) + .await + .map_err(AppError::Database) +} + +pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result, AppError> { + repo::list(pool, tenant_id) + .await + .map_err(AppError::Database) +} + +pub async fn find_by_slug(pool: &SqlitePool, slug: &str) -> Result { + repo::find_by_slug(pool, slug) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound) +} diff --git a/src/identity/mod.rs b/src/identity/mod.rs new file mode 100644 index 0000000..b9a8266 --- /dev/null +++ b/src/identity/mod.rs @@ -0,0 +1,5 @@ +pub mod applications; +pub mod permissions; +pub mod roles; +pub mod service_accounts; +pub mod users; diff --git a/src/identity/permissions.rs b/src/identity/permissions.rs new file mode 100644 index 0000000..4278991 --- /dev/null +++ b/src/identity/permissions.rs @@ -0,0 +1,37 @@ +use sqlx::SqlitePool; + +use crate::{db::repository::permissions as repo, error::AppError}; + +/// Return all permission names held by a user. +pub async fn list_user_permissions( + pool: &SqlitePool, + user_id: &str, +) -> Result, AppError> { + repo::list_for_user(pool, user_id) + .await + .map_err(AppError::Database) +} + +/// Returns true if the user holds the given named permission. +pub async fn has_permission( + pool: &SqlitePool, + user_id: &str, + permission: &str, +) -> Result { + repo::user_has_permission(pool, user_id, permission) + .await + .map_err(AppError::Database) +} + +/// Enforce that a user holds a permission, returning `Forbidden` otherwise. +pub async fn require_permission( + pool: &SqlitePool, + user_id: &str, + permission: &str, +) -> Result<(), AppError> { + if has_permission(pool, user_id, permission).await? { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} diff --git a/src/identity/roles.rs b/src/identity/roles.rs new file mode 100644 index 0000000..02f5757 --- /dev/null +++ b/src/identity/roles.rs @@ -0,0 +1,104 @@ +use sqlx::SqlitePool; + +use crate::{ + db::{models::Role, repository::roles as repo}, + error::AppError, +}; + +/// Assign a named role to a user. No-ops if already assigned. +pub async fn assign_role( + pool: &SqlitePool, + user_id: &str, + role_name: &str, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + let role = repo::find_by_name(pool, role_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound)?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::assign_to_user(&mut tx, user_id, &role.id) + .await + .map_err(AppError::Database)?; + + let metadata = serde_json::json!({ "role": role_name }).to_string(); + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(user_id), + action: "role_assigned", + resource_type: "role", + resource_id: Some(&role.id), + severity: crate::db::models::AuditSeverity::Info, + ip: audit_ip, + ua: audit_ua, + metadata: Some(&metadata), + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + tracing::info!(user_id = %user_id, role = %role_name, "role assigned"); + Ok(()) +} + +/// Remove a named role from a user. No-ops if not assigned. +pub async fn remove_role( + pool: &SqlitePool, + user_id: &str, + role_name: &str, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + let role = repo::find_by_name(pool, role_name) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound)?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::remove_from_user(&mut tx, user_id, &role.id) + .await + .map_err(AppError::Database)?; + + let metadata = serde_json::json!({ "role": role_name }).to_string(); + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(user_id), + action: "role_removed", + resource_type: "role", + resource_id: Some(&role.id), + severity: crate::db::models::AuditSeverity::Info, + ip: audit_ip, + ua: audit_ua, + metadata: Some(&metadata), + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + tracing::info!(user_id = %user_id, role = %role_name, "role removed"); + Ok(()) +} + +/// List all roles defined in the system. +pub async fn list_roles(pool: &SqlitePool) -> Result, AppError> { + repo::list_all(pool).await.map_err(AppError::Database) +} + +/// List roles held by a specific user. +pub async fn list_user_roles(pool: &SqlitePool, user_id: &str) -> Result, AppError> { + repo::list_for_user(pool, user_id) + .await + .map_err(AppError::Database) +} diff --git a/src/identity/service_accounts.rs b/src/identity/service_accounts.rs new file mode 100644 index 0000000..73d2417 --- /dev/null +++ b/src/identity/service_accounts.rs @@ -0,0 +1,88 @@ +use sqlx::SqlitePool; + +use crate::{ + db::{models::ServiceAccount, repository::service_accounts as repo}, + error::AppError, +}; + +pub async fn create( + pool: &SqlitePool, + tenant_id: &str, + name: &str, + description: Option<&str>, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result { + let id = uuid::Uuid::new_v4().to_string(); + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + let sa = repo::create(&mut tx, &id, tenant_id, name, description) + .await + .map_err(AppError::Database)?; + + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: None, + action: "service_account_created", + resource_type: "service_account", + resource_id: Some(&sa.id), + severity: crate::db::models::AuditSeverity::Info, + ip: audit_ip, + ua: audit_ua, + metadata: None, + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + Ok(sa) +} + +pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result, AppError> { + repo::list(pool, tenant_id) + .await + .map_err(AppError::Database) +} + +pub async fn set_enabled( + pool: &SqlitePool, + id: &str, + enabled: bool, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::set_enabled(&mut tx, id, enabled) + .await + .map_err(AppError::Database)?; + + let action = if enabled { + "service_account_enabled" + } else { + "service_account_disabled" + }; + + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: None, + action, + resource_type: "service_account", + resource_id: Some(id), + severity: crate::db::models::AuditSeverity::Warning, + ip: audit_ip, + ua: audit_ua, + metadata: None, + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + Ok(()) +} diff --git a/src/identity/users.rs b/src/identity/users.rs new file mode 100644 index 0000000..bf9a6af --- /dev/null +++ b/src/identity/users.rs @@ -0,0 +1,187 @@ +use sqlx::SqlitePool; + +use crate::{ + config::SecurityConfig, + db::{models::User, repository::users as repo}, + error::AppError, + security::passwords, +}; + +/// Create a new user account in the given tenant. +/// +/// Fails with `Conflict` if the username is already taken. +#[allow(clippy::too_many_arguments)] +pub async fn create_user( + pool: &SqlitePool, + cfg: &SecurityConfig, + tenant_id: &str, + username: &str, + password: &str, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result { + if username.trim().is_empty() { + return Err(AppError::InvalidInput("username cannot be empty".into())); + } + passwords::validate_password_strength(password, false)?; + + if repo::username_exists(pool, tenant_id, username) + .await + .map_err(AppError::Database)? + { + return Err(AppError::Conflict(format!( + "username '{username}' is already taken" + ))); + } + + let id = uuid::Uuid::new_v4().to_string(); + let hash = passwords::hash_password(password, cfg)?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + let user = repo::create(&mut tx, &id, tenant_id, username, &hash) + .await + .map_err(AppError::Database)?; + + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(&user.id), + action: "user_created", + resource_type: "user", + resource_id: Some(&user.id), + severity: crate::db::models::AuditSeverity::Info, + ip: audit_ip, + ua: audit_ua, + metadata: None, + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + tracing::info!(user_id = %user.id, username = %username, "user created"); + Ok(user) +} + +/// Retrieve a user by ID. +pub async fn get_user(pool: &SqlitePool, id: &str) -> Result { + repo::find_by_id(pool, id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound) +} + +/// Retrieve a user by username. +pub async fn get_user_by_username(pool: &SqlitePool, username: &str) -> Result { + repo::find_by_username(pool, username) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound) +} + +/// List all users in a tenant. +pub async fn list_users(pool: &SqlitePool, tenant_id: &str) -> Result, AppError> { + repo::list(pool, tenant_id) + .await + .map_err(AppError::Database) +} + +/// Set a user's status (Active=1, Disabled=2, Locked=3). +pub async fn update_status( + pool: &SqlitePool, + user_id: &str, + status: i32, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + // Verify user exists first + let _user = get_user(pool, user_id).await?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::update_status(&mut tx, user_id, status) + .await + .map_err(AppError::Database)?; + + let action = match status { + 1 => "user_enabled", + 2 => "user_disabled", + 3 => "user_locked", + _ => "user_updated", + }; + + let severity = match status { + 1 => crate::db::models::AuditSeverity::Info, + _ => crate::db::models::AuditSeverity::Warning, + }; + + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(user_id), + action, + resource_type: "user", + resource_id: Some(user_id), + severity, + ip: audit_ip, + ua: audit_ua, + metadata: None, + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + tracing::info!(user_id = %user_id, status = %status, "user status updated"); + Ok(()) +} + +/// Reset a user's password. +pub async fn reset_password( + pool: &SqlitePool, + cfg: &SecurityConfig, + user_id: &str, + new_password: &str, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + let user = get_user(pool, user_id).await?; + let user_roles = crate::db::repository::roles::list_for_user(pool, &user.id) + .await + .map_err(AppError::Database)?; + let is_admin = user_roles.iter().any(|r| r.name == "admin"); + passwords::validate_password_strength(new_password, is_admin)?; + let hash = passwords::hash_password(new_password, cfg)?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::update_password_hash(&mut tx, user_id, &hash) + .await + .map_err(AppError::Database)?; + + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(user_id), + action: "password_reset", + resource_type: "user", + resource_id: Some(user_id), + severity: crate::db::models::AuditSeverity::Warning, + ip: audit_ip, + ua: audit_ua, + metadata: None, + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + tracing::info!(user_id = %user_id, "password reset"); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1dc9618 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +pub mod api; +pub mod audit; +pub mod cli; +pub mod config; +pub mod db; +pub mod error; +pub mod identity; +pub mod middleware; +pub mod security; +pub mod state; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9db282c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,154 @@ +use std::net::SocketAddr; + +use clap::Parser; +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +use nx9_auth::{ + api, + cli::{self, Cli, Commands}, + config::Config, + db, + db::repository::sessions as session_repo, + state::AppState, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Parse CLI arguments first (before any logging so --help works cleanly) + let cli = Cli::parse(); + + // Initialize logging based on the command and verbosity + let is_serve = matches!(cli.command, Commands::Serve); + if is_serve { + // Structured JSON logging for production server deployment + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "nx9_auth=info,tower_http=info".parse().unwrap()), + ) + .with(fmt::layer().json()) + .init(); + } else if cli.verbose { + // Human-readable compact logging for verbosity in subcommands + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "nx9_auth=debug".parse().unwrap()), + ) + .with(fmt::layer().compact()) + .init(); + } else { + // Silence info/debug logging for clean operator CLI commands + tracing_subscriber::registry() + .with( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "nx9_auth=warn".parse().unwrap()), + ) + .with(fmt::layer().compact()) + .init(); + } + + // Load configuration + let config_opt = if matches!( + cli.command, + Commands::Init { .. } | Commands::ConfigPath { .. } + ) { + // For init/config-path commands, a missing override config is fine + if let Some(ref path) = cli.config { + if path.exists() { + Some(Config::load(path)?) + } else { + let mut cfg = Config { + config_path: Some(path.clone()), + ..Default::default() + }; + cfg.resolve_paths(); + Some(cfg) + } + } else { + Config::find_and_load(None)? + } + } else { + Config::find_and_load(cli.config.as_deref())? + }; + + let config = match config_opt { + Some(cfg) => cfg, + None => { + // init and config-path are allowed to run without an existing config file. + // We use default Config structure for them. + if matches!( + cli.command, + Commands::Init { .. } | Commands::ConfigPath { .. } + ) { + let mut cfg = Config::default(); + cfg.resolve_paths(); + cfg + } else { + eprintln!( + "\nError: No configuration found.\n\nRun:\n\n nx9-auth init\n\nOr if running in Docker:\n\n docker exec -it nx9-auth nx9-auth init\n" + ); + std::process::exit(1); + } + } + }; + + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + git_commit = env!("GIT_COMMIT"), + "nx9-auth starting" + ); + + // Dispatch to serve or CLI command + match cli.command { + Commands::Serve => run_server(config).await, + cmd => cli::run(cmd, config).await, + } +} + +/// Start the HTTP server (Milestone B+). +async fn run_server(config: Config) -> anyhow::Result<()> { + // Open DB pool and run migrations + let pool = db::create_pool(&config.database.path).await?; + db::run_migrations(&pool).await?; + + // Cleanup expired sessions at startup (one-shot, fire-and-forget) + let pool_clone = pool.clone(); + tokio::spawn(async move { + match session_repo::cleanup_expired(&pool_clone).await { + Ok(n) => tracing::info!(removed = n, "expired sessions cleaned up"), + Err(e) => tracing::warn!(error = %e, "session cleanup failed"), + } + }); + + // Build application state + let state = AppState::new(pool, config.clone()); + + // Build router + let app = api::router::build(state); + + // Bind and serve + let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port) + .parse() + .map_err(|e| anyhow::anyhow!("invalid bind address: {}", e))?; + + let listener = tokio::net::TcpListener::bind(addr).await?; + + tracing::info!( + address = %addr, + "server listening" + ); + + println!( + "\nServer listening on:\n\n http://{}\n\nHealth:\n\n http://{}/health\n", + addr, addr + ); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; + + Ok(()) +} diff --git a/src/middleware/audit.rs b/src/middleware/audit.rs new file mode 100644 index 0000000..d898ae8 --- /dev/null +++ b/src/middleware/audit.rs @@ -0,0 +1,50 @@ +use axum::{ + extract::{ConnectInfo, FromRequestParts}, + http::request::Parts, +}; +use std::net::SocketAddr; + +/// Request context for audit logging — captures IP and User-Agent. +/// +/// Handlers include this extractor to forward client metadata to the audit log +/// without threading raw request headers through the call stack. +#[derive(Debug, Clone, Default)] +pub struct AuditContext { + pub ip_address: Option, + pub user_agent: Option, +} + +impl FromRequestParts for AuditContext +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Prefer X-Forwarded-For (set by reverse proxies like Nginx) + let ip_address = parts + .headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(|s| s.trim().to_string()) + .or_else(|| { + // Fall back to direct peer address (requires ConnectInfo extension) + parts + .extensions + .get::>() + .map(|ci| ci.0.ip().to_string()) + }); + + let user_agent = parts + .headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + Ok(AuditContext { + ip_address, + user_agent, + }) + } +} diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..c7abed4 --- /dev/null +++ b/src/middleware/auth.rs @@ -0,0 +1,96 @@ +use axum::{ + extract::{FromRef, FromRequestParts}, + http::request::Parts, +}; +use axum_extra::extract::CookieJar; + +use crate::{ + db::models::User, + db::repository::users as user_repo, + error::AppError, + security::{sessions, tokens}, + state::AppState, +}; + +/// Describes how the current request was authenticated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthMethod { + Session, + Token, +} + +/// Axum extractor that resolves the authenticated user from either a session +/// cookie or a Bearer token in the Authorization header. +/// +/// Handlers that need an authenticated user simply include `auth: AuthUser` +/// in their parameter list. +#[derive(Debug, Clone)] +pub struct AuthUser { + pub user: User, + pub method: AuthMethod, + /// Session ID — populated when `method == Session`, used for logout. + pub session_id: Option, +} + +impl FromRequestParts for AuthUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + + // 1. Try session cookie first + let jar = CookieJar::from_headers(&parts.headers); + if let Some(cookie) = jar.get(sessions::SESSION_COOKIE) { + let raw = cookie.value(); + if let Some(session) = + sessions::validate_session(&app_state.pool, raw, &app_state.config.security).await? + { + let user = user_repo::find_by_id(&app_state.pool, &session.user_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::Unauthorized)?; + + if !user.is_active() { + return Err(AppError::Unauthorized); + } + + return Ok(AuthUser { + user, + method: AuthMethod::Session, + session_id: Some(session.id), + }); + } + } + + // 2. Try Bearer token in Authorization header + if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION) { + if let Ok(value) = auth_header.to_str() { + if let Some(raw) = value.strip_prefix("Bearer ") { + if let Some(token) = tokens::validate_token(&app_state.pool, raw.trim()).await? + { + let user = user_repo::find_by_id(&app_state.pool, &token.user_id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::Unauthorized)?; + + if !user.is_active() { + return Err(AppError::Unauthorized); + } + + return Ok(AuthUser { + user, + method: AuthMethod::Token, + session_id: None, + }); + } + } + } + } + + Err(AppError::Unauthorized) + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..16decbb --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1,3 @@ +pub mod audit; +pub mod auth; +pub mod permissions; diff --git a/src/middleware/permissions.rs b/src/middleware/permissions.rs new file mode 100644 index 0000000..de4603c --- /dev/null +++ b/src/middleware/permissions.rs @@ -0,0 +1,12 @@ +use sqlx::SqlitePool; + +use crate::{error::AppError, identity::permissions}; + +/// Enforce that the calling user has the given permission. +/// +/// Alias for `permissions::require_permission` — imported in handlers for +/// readability: `require(pool, user_id, "users:create").await?` +#[inline] +pub async fn require(pool: &SqlitePool, user_id: &str, permission: &str) -> Result<(), AppError> { + permissions::require_permission(pool, user_id, permission).await +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..5edf9eb --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1,6 @@ +pub mod passwords; +pub mod rate_limit; +pub mod sessions; +pub mod tokens; + +pub use rate_limit::RateLimiter; diff --git a/src/security/passwords.rs b/src/security/passwords.rs new file mode 100644 index 0000000..815e43e --- /dev/null +++ b/src/security/passwords.rs @@ -0,0 +1,143 @@ +use argon2::{ + Argon2, Params, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +use crate::{config::SecurityConfig, error::AppError}; + +/// Hash a plaintext password using Argon2id with configurable cost parameters. +/// +/// Returns a PHC-format string (e.g. `$argon2id$v=19$...`) that includes the +/// salt and all parameters. This string is safe to store directly in the DB. +pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result { + let params = Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + Params::new( + cfg.argon2_memory, + cfg.argon2_iterations, + cfg.argon2_parallelism, + None, + ) + .map_err(|e| { + tracing::error!(error = %e, "invalid argon2 params"); + AppError::Internal + })?, + ); + + let salt = SaltString::generate(&mut OsRng); + let hash = params + .hash_password(password.as_bytes(), &salt) + .map_err(|e| { + tracing::error!(error = %e, "argon2 hashing failed"); + AppError::Internal + })?; + + Ok(hash.to_string()) +} + +/// Verify a plaintext password against a stored Argon2id PHC hash. +/// +/// Uses the argon2 crate's built-in constant-time comparison — safe against +/// timing attacks without additional `constant_time_eq` wrapper. +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash).map_err(|e| { + tracing::error!(error = %e, "failed to parse password hash"); + AppError::Internal + })?; + + match Argon2::default().verify_password(password.as_bytes(), &parsed) { + Ok(()) => Ok(true), + Err(argon2::password_hash::Error::Password) => Ok(false), + Err(e) => { + tracing::error!(error = %e, "argon2 verification error"); + Err(AppError::Internal) + } + } +} + +/// Execute a dummy Argon2id hash with the currently configured parameters. +/// +/// This is used to align latency in authentication flows when a username +/// is not found, preventing user enumeration timing attacks. +pub fn verify_dummy(cfg: &SecurityConfig) -> Result<(), AppError> { + let _ = hash_password("dummy_password_for_timing_attacks", cfg)?; + Ok(()) +} + +/// Validate password strength against common patterns and minimum length. +/// +/// For admin accounts (is_admin = true), enforces 12-char minimum. +/// For standard accounts, enforces 8-char minimum. +/// Both reject common passwords like "password", "admin123", "qwerty", "12345678". +pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(), AppError> { + let min_len = if is_admin { 12 } else { 8 }; + if password.len() < min_len { + return Err(AppError::InvalidInput(format!( + "password must be at least {min_len} characters long" + ))); + } + + let normalized = password.to_lowercase(); + let weak_list = [ + "password", + "admin123", + "qwerty", + "12345678", + "123456789", + "administrator", + "nx9-auth", + "nx9auth", + ]; + + for weak in &weak_list { + if normalized.contains(weak) { + return Err(AppError::InvalidInput( + "password contains a weak or common sequence".to_string(), + )); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::SecurityConfig; + + fn test_cfg() -> SecurityConfig { + SecurityConfig { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 4096, // low cost for tests + argon2_iterations: 1, + argon2_parallelism: 1, + } + } + + #[test] + fn test_hash_and_verify() { + let cfg = test_cfg(); + let pass = "correct_password_123"; + let hash = hash_password(pass, &cfg).unwrap(); + assert!(verify_password(pass, &hash).unwrap()); + assert!(!verify_password("wrong_password", &hash).unwrap()); + } + + #[test] + fn test_strength_validation() { + // Standard user length + assert!(validate_password_strength("super_secure_passphrase_123", false).is_ok()); + assert!(validate_password_strength("short", false).is_err()); + + // Admin length + assert!(validate_password_strength("super_secure_admin_passphrase_123", true).is_ok()); + assert!(validate_password_strength("short_admin", true).is_err()); + + // Weak password checks + assert!(validate_password_strength("my-password-is-weak", false).is_err()); + assert!(validate_password_strength("admin1234567", false).is_err()); + } +} diff --git a/src/security/rate_limit.rs b/src/security/rate_limit.rs new file mode 100644 index 0000000..0de5b0f --- /dev/null +++ b/src/security/rate_limit.rs @@ -0,0 +1,191 @@ +use std::{ + collections::VecDeque, + net::IpAddr, + sync::Arc, + time::{Duration, Instant}, +}; + +use dashmap::DashMap; + +use crate::error::AppError; + +/// Per-IP tracking state. +#[derive(Debug)] +struct IpState { + /// Failure timestamps within the current window. + window: VecDeque, + /// Number of times this IP has been locked out (escalation counter). + lockout_count: u32, + /// When the current lockout expires. `None` if not locked. + locked_until: Option, +} + +impl IpState { + fn new() -> Self { + Self { + window: VecDeque::new(), + lockout_count: 0, + locked_until: None, + } + } +} + +/// In-memory escalating rate limiter for login attempts. +/// +/// Policy: +/// - Track failures per IP in a 15-minute sliding window. +/// - After 5 failures → lock for 15 minutes (level 1). +/// - After another 5 failures post-unlock → lock for 1 hour (level 2). +/// - After another 5 failures post-unlock → lock for 24 hours (level 3+). +/// +/// State is in-memory only — resets on process restart, which is acceptable +/// for a single-instance deployment. +#[derive(Debug)] +pub struct RateLimiter { + state: DashMap, + /// Window for failure counting. + window: Duration, + /// Max failures per window before lockout. + max_failures: u32, +} + +impl RateLimiter { + pub fn new() -> Arc { + Arc::new(Self { + state: DashMap::new(), + window: Duration::from_secs(15 * 60), + max_failures: 5, + }) + } + + /// Calculate lockout duration based on escalation level. + fn lockout_duration(level: u32) -> Duration { + match level { + 1 => Duration::from_secs(15 * 60), // 15 minutes + 2 => Duration::from_secs(60 * 60), // 1 hour + _ => Duration::from_secs(24 * 60 * 60), // 24 hours + } + } + + /// Check if the given IP is currently allowed to attempt a login. + /// + /// Returns `Err(AppError::RateLimited)` if the IP is locked out. + pub fn check(&self, ip: IpAddr) -> Result<(), AppError> { + let state = self.state.get(&ip); + if let Some(s) = state { + if let Some(until) = s.locked_until { + if Instant::now() < until { + return Err(AppError::RateLimited); + } + } + } + Ok(()) + } + + /// Record a failed login attempt for an IP. + /// + /// Triggers lockout if the failure threshold is reached. + pub fn record_failure(&self, ip: IpAddr) { + let mut s = self.state.entry(ip).or_insert_with(IpState::new); + let now = Instant::now(); + + // Clear the lockout if it has expired + if let Some(until) = s.locked_until { + if now >= until { + s.locked_until = None; + } + } + + // Prune old failures outside the window + let cutoff = now - self.window; + while s.window.front().is_some_and(|&t| t < cutoff) { + s.window.pop_front(); + } + + s.window.push_back(now); + + if s.window.len() >= self.max_failures as usize { + s.lockout_count += 1; + let duration = Self::lockout_duration(s.lockout_count); + s.locked_until = Some(now + duration); + s.window.clear(); + + tracing::warn!( + ip = %ip, + lockout_count = s.lockout_count, + duration_secs = duration.as_secs(), + "login rate limit triggered" + ); + } + } + + /// Record a successful login — clear failure history for this IP. + pub fn record_success(&self, ip: IpAddr) { + if let Some(mut s) = self.state.get_mut(&ip) { + s.window.clear(); + s.locked_until = None; + // Do NOT reset lockout_count — escalation persists across successful logins + } + } +} + +impl Default for RateLimiter { + fn default() -> Self { + Self { + state: DashMap::new(), + window: Duration::from_secs(15 * 60), + max_failures: 5, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn test_rate_limiter() { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let limiter = RateLimiter { + state: DashMap::new(), + window: Duration::from_secs(60), + max_failures: 3, + }; + + // Initially OK + assert!(limiter.check(ip).is_ok()); + + // First failure + limiter.record_failure(ip); + assert!(limiter.check(ip).is_ok()); + + // Second failure + limiter.record_failure(ip); + assert!(limiter.check(ip).is_ok()); + + // Third failure -> should trigger lockout + limiter.record_failure(ip); + assert!(limiter.check(ip).is_err()); + + // Clear via success + limiter.record_success(ip); + assert!(limiter.check(ip).is_ok()); + } + + #[test] + fn test_lockout_escalation() { + assert_eq!( + RateLimiter::lockout_duration(1), + Duration::from_secs(15 * 60) + ); + assert_eq!( + RateLimiter::lockout_duration(2), + Duration::from_secs(60 * 60) + ); + assert_eq!( + RateLimiter::lockout_duration(3), + Duration::from_secs(24 * 60 * 60) + ); + } +} diff --git a/src/security/sessions.rs b/src/security/sessions.rs new file mode 100644 index 0000000..0deed82 --- /dev/null +++ b/src/security/sessions.rs @@ -0,0 +1,131 @@ +use rand::RngCore; +use sqlx::SqlitePool; + +use crate::{ + config::SecurityConfig, + db::{models::Session, repository::sessions as repo}, + error::AppError, +}; + +pub const SESSION_COOKIE: &str = "nx9_session"; + +/// Generate a cryptographically random session token (32 bytes → 64 hex chars). +pub fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Hash a raw session token using BLAKE3 (constant-time, fast). +pub fn hash_session_token(raw: &str) -> String { + hex::encode(blake3::hash(raw.as_bytes()).as_bytes()) +} + +/// Create a new session in the database. +/// +/// Returns `(Session row, raw_token)` — the raw token is placed in the cookie +/// and never stored. Only the BLAKE3 hash is persisted. +pub async fn create_session( + pool: &SqlitePool, + user_id: &str, + ip_address: Option<&str>, + user_agent: Option<&str>, + cfg: &SecurityConfig, +) -> Result<(Session, String), AppError> { + let raw_token = generate_session_token(); + let token_hash = hash_session_token(&raw_token); + + // Absolute expiry = now + session_absolute_ttl_days + let expires_at = + chrono::Utc::now() + chrono::Duration::days(cfg.session_absolute_ttl_days as i64); + let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let id = uuid::Uuid::new_v4().to_string(); + + let session = repo::create( + pool, + &id, + user_id, + &token_hash, + ip_address, + user_agent, + &expires_at_str, + ) + .await + .map_err(AppError::Database)?; + + Ok((session, raw_token)) +} + +/// Validate a raw session token from a cookie. +/// +/// Enforces both absolute TTL and idle timeout. Touches `last_seen_at` on +/// every successful validation. +pub async fn validate_session( + pool: &SqlitePool, + raw_token: &str, + cfg: &SecurityConfig, +) -> Result, AppError> { + let token_hash = hash_session_token(raw_token); + + let session = repo::find_by_token_hash(pool, &token_hash) + .await + .map_err(AppError::Database)?; + + let Some(session) = session else { + return Ok(None); + }; + + let now = chrono::Utc::now(); + + // Check absolute expiry + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) { + if now > expires { + repo::revoke(pool, &session.id) + .await + .map_err(AppError::Database)?; + return Ok(None); + } + } + + // Check idle timeout + if let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at) { + let idle_deadline = last_seen + chrono::Duration::hours(cfg.session_ttl_hours as i64); + if now > idle_deadline { + repo::revoke(pool, &session.id) + .await + .map_err(AppError::Database)?; + return Ok(None); + } + } + + // Touch last_seen (fire-and-forget — don't fail the request if this errors) + let _ = repo::update_last_seen(pool, &session.id).await; + + Ok(Some(session)) +} + +/// Revoke a session by its ID. +pub async fn revoke_session(pool: &SqlitePool, session_id: &str) -> Result<(), AppError> { + repo::revoke(pool, session_id) + .await + .map_err(AppError::Database) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_generation_and_hashing() { + let t1 = generate_session_token(); + let t2 = generate_session_token(); + assert_ne!(t1, t2); + assert_eq!(t1.len(), 64); + + let h1 = hash_session_token(&t1); + let h2 = hash_session_token(&t1); + assert_eq!(h1, h2); + assert_ne!(h1, t1); + } +} diff --git a/src/security/tokens.rs b/src/security/tokens.rs new file mode 100644 index 0000000..9287de2 --- /dev/null +++ b/src/security/tokens.rs @@ -0,0 +1,165 @@ +use rand::RngCore; +use sqlx::SqlitePool; + +use crate::{ + config::SecurityConfig, + db::{models::ApiToken, repository::tokens as repo}, + error::AppError, +}; + +/// Prefix for all personal access tokens. +pub const PAT_PREFIX: &str = "nx9_pat_"; + +/// Generate a new personal access token string. +/// +/// Format: `nx9_pat_<64 hex chars>` (32 random bytes) +pub fn generate_pat() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("{}{}", PAT_PREFIX, hex::encode(bytes)) +} + +/// Hash a raw token string using BLAKE3. +pub fn hash_token(raw: &str) -> String { + hex::encode(blake3::hash(raw.as_bytes()).as_bytes()) +} + +/// Create a new personal access token for a user. +/// +/// Returns `(ApiToken row, raw_token)` — the raw token is shown once and +/// never stored. Only the BLAKE3 hash is persisted. +pub async fn create_token( + pool: &SqlitePool, + user_id: &str, + name: &str, + cfg: &SecurityConfig, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(ApiToken, String), AppError> { + let raw = generate_pat(); + let hash = hash_token(&raw); + let id = uuid::Uuid::new_v4().to_string(); + + let expires_at = chrono::Utc::now() + chrono::Duration::days(cfg.token_ttl_days as i64); + let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(); + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + let token = repo::create(&mut tx, &id, user_id, name, &hash, Some(&expires_at_str)) + .await + .map_err(AppError::Database)?; + + let metadata = serde_json::json!({ "token_id": token.id, "name": name }).to_string(); + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(user_id), + action: "token_created", + resource_type: "token", + resource_id: Some(&token.id), + severity: crate::db::models::AuditSeverity::Info, + ip: audit_ip, + ua: audit_ua, + metadata: Some(&metadata), + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + + Ok((token, raw)) +} + +/// Revoke a personal access token. +pub async fn revoke_token( + pool: &SqlitePool, + id: &str, + audit_actor_id: Option<&str>, + audit_ip: Option<&str>, + audit_ua: Option<&str>, +) -> Result<(), AppError> { + let token = repo::find_by_id(pool, id) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound)?; + + let mut tx = pool.begin().await.map_err(AppError::Database)?; + + repo::revoke(&mut tx, id) + .await + .map_err(AppError::Database)?; + + let metadata = serde_json::json!({ "token_id": id, "name": token.name }).to_string(); + crate::audit::log( + &mut tx, + crate::audit::AuditEvent { + actor_id: audit_actor_id, + target_id: Some(&token.user_id), + action: "token_revoked", + resource_type: "token", + resource_id: Some(id), + severity: crate::db::models::AuditSeverity::Warning, + ip: audit_ip, + ua: audit_ua, + metadata: Some(&metadata), + }, + ) + .await?; + + tx.commit().await.map_err(AppError::Database)?; + Ok(()) +} + +/// Validate a raw PAT from an Authorization header. +/// +/// Strips the `nx9_pat_` prefix, hashes it, and looks it up. Returns `None` +/// if the token is unknown, revoked, or expired. +pub async fn validate_token(pool: &SqlitePool, raw: &str) -> Result, AppError> { + // Must have the expected prefix + if !raw.starts_with(PAT_PREFIX) { + return Ok(None); + } + + let hash = hash_token(raw); + let token = repo::find_by_hash(pool, &hash) + .await + .map_err(AppError::Database)?; + + let Some(token) = token else { + return Ok(None); + }; + + // Check expiry if set + if let Some(ref exp) = token.expires_at { + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(exp) { + if chrono::Utc::now() > expires { + return Ok(None); + } + } + } + + // Touch last_used_at (fire-and-forget) + let _ = repo::update_last_used(pool, &token.id).await; + + Ok(Some(token)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_generation_and_prefix() { + let t1 = generate_pat(); + let t2 = generate_pat(); + assert_ne!(t1, t2); + assert!(t1.starts_with(PAT_PREFIX)); + + let h1 = hash_token(&t1); + let h2 = hash_token(&t1); + assert_eq!(h1, h2); + assert_ne!(h1, t1); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..1d3988b --- /dev/null +++ b/src/state.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use sqlx::SqlitePool; + +use crate::{config::Config, security::RateLimiter}; + +/// Shared application state injected into every Axum handler via `State`. +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, + pub config: Arc, + pub rate_limiter: Arc, +} + +impl AppState { + pub fn new(pool: SqlitePool, config: Config) -> Self { + Self { + pool, + config: Arc::new(config), + rate_limiter: RateLimiter::new(), + } + } +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..88d3abe --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,287 @@ +use nx9_auth::cli::{Commands, run}; +use nx9_auth::config::Config; +use std::fs; +use std::path::{Path, PathBuf}; + +fn setup_test_db(db_path: &str) { + let _ = fs::remove_file(db_path); +} + +fn teardown_test_db(db_path: &str) { + let _ = fs::remove_file(db_path); + let _ = fs::remove_file(format!("{}-wal", db_path)); + let _ = fs::remove_file(format!("{}-shm", db_path)); +} + +#[tokio::test] +async fn test_path_expansion() { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string()); + + let mut config = Config::default(); + config.database.path = "~/test_subdir/test.db".to_string(); + config.resolve_paths(); + + let expected = Path::new(&home).join("test_subdir/test.db"); + assert_eq!(config.database.path, expected.to_string_lossy().to_string()); +} + +#[tokio::test] +async fn test_backup_validation_and_integrity() { + let db_path = "test_cli_backup.db"; + setup_test_db(db_path); + + let mut config = Config::default(); + config.database.path = db_path.to_string(); + + // 1. Initialize DB and run migrations + let pool = nx9_auth::db::create_pool(db_path).await.unwrap(); + nx9_auth::db::run_migrations(&pool).await.unwrap(); + + // 2. Validate backup safety rejects active DB + let res = run( + Commands::Backup { + path: PathBuf::from(db_path), + }, + config.clone(), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Backup destination cannot be the active database file") + ); + + // Reject WAL file + let wal_path = format!("{}-wal", db_path); + let res = run( + Commands::Backup { + path: PathBuf::from(&wal_path), + }, + config.clone(), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Backup destination cannot be the active WAL file") + ); + + // Reject SHM file + let shm_path = format!("{}-shm", db_path); + let res = run( + Commands::Backup { + path: PathBuf::from(&shm_path), + }, + config.clone(), + ) + .await; + assert!(res.is_err()); + assert!( + res.unwrap_err() + .to_string() + .contains("Backup destination cannot be the active SHM file") + ); + + // 3. Test successful backup + let backup_path = "test_cli_backup_dest.db"; + let _ = fs::remove_file(backup_path); + + let res = run( + Commands::Backup { + path: PathBuf::from(backup_path), + }, + config.clone(), + ) + .await; + assert!(res.is_ok()); + assert!(Path::new(backup_path).exists()); + + // 4. Verify integrity of the backup database + let backup_pool = nx9_auth::db::create_pool(backup_path).await.unwrap(); + let integrity: (String,) = sqlx::query_as("PRAGMA integrity_check") + .fetch_one(&backup_pool) + .await + .unwrap(); + assert_eq!(integrity.0, "ok"); + + // Clean up + teardown_test_db(db_path); + teardown_test_db(backup_path); +} + +#[tokio::test] +async fn test_cli_config_path_json() { + let mut config = Config::default(); + config.database.path = "test.db".to_string(); + + let res = run(Commands::ConfigPath { json: true }, config.clone()).await; + assert!(res.is_ok()); + + let res = run(Commands::ConfigPath { json: false }, config).await; + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_cli_init_non_interactive() { + let db_path = "test_cli_init.db"; + + // Clean up + let _ = fs::remove_file(db_path); + + let mut config = Config::default(); + config.database.path = db_path.to_string(); + + // Run init command in non-interactive mode + let res = run( + Commands::Init { + non_interactive: true, + skip_admin: false, + force: false, + admin_user: Some("init_admin".to_string()), + admin_password: Some("S3cur3#P@ssw0rd$N0S3qu3nc3!".to_string()), + }, + config.clone(), + ) + .await; + + if let Err(ref e) = res { + println!("INIT ERROR: {:?}", e); + } + assert!(res.is_ok()); + + // Verify DB exists + assert!(Path::new(db_path).exists()); + + // Verify admin user is created in database + let pool = nx9_auth::db::create_pool(db_path).await.unwrap(); + let admin_exists = nx9_auth::db::repository::users::username_exists( + &pool, + nx9_auth::db::models::Tenant::DEFAULT_ID, + "init_admin", + ) + .await + .unwrap(); + assert!(admin_exists); + + // Clean up + teardown_test_db(db_path); +} + +#[tokio::test] +async fn test_cli_init_skip_admin() { + let db_path = "test_cli_init_skip_admin.db"; + + // Clean up + let _ = fs::remove_file(db_path); + + let mut config = Config::default(); + config.database.path = db_path.to_string(); + + // Run init command with skip_admin + let res = run( + Commands::Init { + non_interactive: true, + skip_admin: true, + force: false, + admin_user: None, + admin_password: None, + }, + config.clone(), + ) + .await; + + assert!(res.is_ok()); + + // Verify DB exists + assert!(Path::new(db_path).exists()); + + // Verify no admin users exist + let pool = nx9_auth::db::create_pool(db_path).await.unwrap(); + let admin_count = nx9_auth::db::repository::users::count_admins(&pool) + .await + .unwrap(); + assert_eq!(admin_count, 0); + + // Clean up + teardown_test_db(db_path); +} + +#[tokio::test] +async fn test_cli_show_user_and_token() { + let db_path = "test_cli_show.db"; + setup_test_db(db_path); + + let mut config = Config::default(); + config.database.path = db_path.to_string(); + + // 1. Init DB and seed user + let pool = nx9_auth::db::create_pool(db_path).await.unwrap(); + nx9_auth::db::run_migrations(&pool).await.unwrap(); + + let user = nx9_auth::identity::users::create_user( + &pool, + &config.security, + nx9_auth::db::models::Tenant::DEFAULT_ID, + "show_test_user", + "S3cur3#P@ssw0rd$N0S3qu3nc3!", + None, + None, + None, + ) + .await + .unwrap(); + + // Assign role + nx9_auth::identity::roles::assign_role(&pool, &user.id, "viewer", None, None, None) + .await + .unwrap(); + + // Create a token + let (token, _raw) = nx9_auth::security::tokens::create_token( + &pool, + &user.id, + "test-token", + &config.security, + None, + None, + None, + ) + .await + .unwrap(); + + // 2. Run show-user command + let res = run( + Commands::ShowUser { + id_or_username: "show_test_user".to_string(), + permissions: true, + }, + config.clone(), + ) + .await; + assert!(res.is_ok()); + + let res = run( + Commands::ShowUser { + id_or_username: user.id.clone(), + permissions: false, + }, + config.clone(), + ) + .await; + assert!(res.is_ok()); + + // 3. Run show-token command + let res = run( + Commands::ShowToken { + id: token.id.clone(), + }, + config.clone(), + ) + .await; + assert!(res.is_ok()); + + // Clean up + teardown_test_db(db_path); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..7415d8c --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,1304 @@ +use axum::{ + body::Body, + http::{Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::Value; +use tower::ServiceExt; + +use nx9_auth::{ + api, + config::{Config, SecurityConfig}, + db::{ + self, + models::{ApiToken, Role, Tenant, User, UserStatus}, + repository::{roles as role_repo, tokens as token_repo}, + }, + error::AppError, + identity::{ + permissions as identity_perms, roles as identity_roles_real, users as identity_users_real, + }, + security::{sessions, tokens as tokens_real}, + state::AppState, +}; + +#[allow(dead_code)] +mod identity_users { + use super::AppError; + use super::SecurityConfig; + use super::User; + use super::identity_users_real; + use sqlx::SqlitePool; + + pub async fn create_user( + pool: &SqlitePool, + cfg: &SecurityConfig, + tenant_id: &str, + username: &str, + password: &str, + ) -> Result { + identity_users_real::create_user(pool, cfg, tenant_id, username, password, None, None, None) + .await + } + + pub async fn get_user(pool: &SqlitePool, id: &str) -> Result { + identity_users_real::get_user(pool, id).await + } + + pub async fn get_user_by_username(pool: &SqlitePool, username: &str) -> Result { + identity_users_real::get_user_by_username(pool, username).await + } + + pub async fn list_users(pool: &SqlitePool, tenant_id: &str) -> Result, AppError> { + identity_users_real::list_users(pool, tenant_id).await + } + + pub async fn update_status( + pool: &SqlitePool, + user_id: &str, + status: i32, + ) -> Result<(), AppError> { + identity_users_real::update_status(pool, user_id, status, None, None, None).await + } + + pub async fn reset_password( + pool: &SqlitePool, + cfg: &SecurityConfig, + user_id: &str, + new_password: &str, + ) -> Result<(), AppError> { + identity_users_real::reset_password(pool, cfg, user_id, new_password, None, None, None) + .await + } +} + +#[allow(dead_code)] +mod identity_roles { + use super::AppError; + use super::Role; + use super::identity_roles_real; + use sqlx::SqlitePool; + + pub async fn assign_role( + pool: &SqlitePool, + user_id: &str, + role_name: &str, + ) -> Result<(), AppError> { + identity_roles_real::assign_role(pool, user_id, role_name, None, None, None).await + } + + pub async fn list_roles(pool: &SqlitePool) -> Result, AppError> { + identity_roles_real::list_roles(pool).await + } + + pub async fn list_user_roles(pool: &SqlitePool, user_id: &str) -> Result, AppError> { + identity_roles_real::list_user_roles(pool, user_id).await + } +} + +#[allow(dead_code)] +mod tokens { + use super::ApiToken; + use super::AppError; + use super::SecurityConfig; + use super::tokens_real; + use sqlx::SqlitePool; + + pub fn generate_pat() -> String { + tokens_real::generate_pat() + } + + pub fn hash_token(raw: &str) -> String { + tokens_real::hash_token(raw) + } + + pub async fn create_token( + pool: &SqlitePool, + user_id: &str, + name: &str, + cfg: &SecurityConfig, + ) -> Result<(ApiToken, String), AppError> { + tokens_real::create_token(pool, user_id, name, cfg, None, None, None).await + } + + pub async fn validate_token( + pool: &SqlitePool, + raw: &str, + ) -> Result, AppError> { + tokens_real::validate_token(pool, raw).await + } +} + +async fn setup_test_db() -> (sqlx::SqlitePool, String) { + let db_id = uuid::Uuid::new_v4().to_string(); + let db_path = format!("target/test_{}.db", db_id); + let pool = db::create_pool(&db_path) + .await + .expect("Failed to create test pool"); + db::run_migrations(&pool) + .await + .expect("Failed to run test migrations"); + (pool, db_path) +} + +async fn teardown_test_db(path: String) { + let _ = std::fs::remove_file(path); +} + +fn test_security_config() -> SecurityConfig { + SecurityConfig { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 4096, // low cost for fast tests + argon2_iterations: 1, + argon2_parallelism: 1, + } +} + +fn test_config(db_path: String) -> Config { + Config { + server: nx9_auth::config::ServerConfig { + host: "127.0.0.1".to_string(), + port: 8655, + }, + database: nx9_auth::config::DatabaseConfig { path: db_path }, + security: test_security_config(), + audit: nx9_auth::config::AuditConfig { enabled: true }, + ..Default::default() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Database & Migration Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_db_migration_creates_default_tenant() { + let (pool, db_path) = setup_test_db().await; + let exists = sqlx::query("SELECT 1 FROM tenants WHERE id = ?") + .bind(Tenant::DEFAULT_ID) + .fetch_optional(&pool) + .await + .unwrap() + .is_some(); + assert!(exists); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_db_migration_seeds_admin_role() { + let (pool, db_path) = setup_test_db().await; + let role = role_repo::find_by_name(&pool, "admin").await.unwrap(); + assert!(role.is_some()); + assert_eq!(role.unwrap().name, "admin"); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_db_migration_seeds_viewer_role() { + let (pool, db_path) = setup_test_db().await; + let role = role_repo::find_by_name(&pool, "viewer").await.unwrap(); + assert!(role.is_some()); + assert_eq!(role.unwrap().name, "viewer"); + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// User Repository Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_repo_create_user_success() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "repo_user_1", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + assert_eq!(user.username, "repo_user_1"); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_create_user_empty_username() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let res = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + " ", + "super_secure_passphrase_123", + ) + .await; + assert!(res.is_err()); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_create_user_conflict() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let _ = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "repo_user_conflict", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + let res = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "repo_user_conflict", + "super_secure_passphrase_123", + ) + .await; + assert!(res.is_err()); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_find_user_by_id() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "find_by_id_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + let found = identity_users::get_user(&pool, &user.id).await.unwrap(); + assert_eq!(found.username, "find_by_id_user"); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_find_user_by_username() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let _ = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "find_by_username_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + let found = identity_users::get_user_by_username(&pool, "find_by_username_user") + .await + .unwrap(); + assert_eq!(found.username, "find_by_username_user"); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_update_status() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "status_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + identity_users::update_status(&pool, &user.id, UserStatus::Disabled as i32) + .await + .unwrap(); + let updated = identity_users::get_user(&pool, &user.id).await.unwrap(); + assert_eq!(updated.status, UserStatus::Disabled as i32); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_reset_password_strength_standard() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "pwd_reset_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + // Standard user password reset fails with too short + assert!( + identity_users::reset_password(&pool, &sec_cfg, &user.id, "short") + .await + .is_err() + ); + // Fails with weak password + assert!( + identity_users::reset_password(&pool, &sec_cfg, &user.id, "password12345") + .await + .is_err() + ); + // Succeeds with valid + assert!( + identity_users::reset_password(&pool, &sec_cfg, &user.id, "super_secure_new_phrase_123") + .await + .is_ok() + ); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_repo_reset_password_strength_admin() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "pwd_reset_admin", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &user.id, "admin") + .await + .unwrap(); + + // Admin reset fails with 8 characters (requires 12) + assert!( + identity_users::reset_password(&pool, &sec_cfg, &user.id, "short_pwd") + .await + .is_err() + ); + // Succeeds with >= 12 chars + assert!( + identity_users::reset_password( + &pool, + &sec_cfg, + &user.id, + "super_secure_admin_new_phrase_123" + ) + .await + .is_ok() + ); + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Role & Permission Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_role_assignment() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "role_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + identity_roles::assign_role(&pool, &user.id, "viewer") + .await + .unwrap(); + let user_roles = role_repo::list_for_user(&pool, &user.id).await.unwrap(); + assert!(user_roles.iter().any(|r| r.name == "viewer")); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_role_removal() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "role_rm_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + identity_roles::assign_role(&pool, &user.id, "viewer") + .await + .unwrap(); + let role = role_repo::find_by_name(&pool, "viewer") + .await + .unwrap() + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + role_repo::remove_from_user(&mut tx, &user.id, &role.id) + .await + .unwrap(); + tx.commit().await.unwrap(); + let user_roles = role_repo::list_for_user(&pool, &user.id).await.unwrap(); + assert!(user_roles.is_empty()); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_permission_listing_admin() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "perm_admin", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &user.id, "admin") + .await + .unwrap(); + + let perms = identity_perms::list_user_permissions(&pool, &user.id) + .await + .unwrap(); + assert!(perms.contains(&"users:create".to_string())); + assert!(perms.contains(&"users:delete".to_string())); + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session Lifecycle Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_session_creation_success() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "sess_create_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let (session, raw_token) = + sessions::create_session(&pool, &user.id, Some("127.0.0.1"), None, &sec_cfg) + .await + .unwrap(); + assert_eq!(session.user_id, user.id); + assert_eq!(raw_token.len(), 64); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_session_validation_valid_token() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "sess_val_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let (_, raw_token) = + sessions::create_session(&pool, &user.id, Some("127.0.0.1"), None, &sec_cfg) + .await + .unwrap(); + let validated = sessions::validate_session(&pool, &raw_token, &sec_cfg) + .await + .unwrap(); + assert!(validated.is_some()); + assert_eq!(validated.unwrap().user_id, user.id); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_session_validation_revoked_token() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "sess_rev_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let (session, raw_token) = + sessions::create_session(&pool, &user.id, Some("127.0.0.1"), None, &sec_cfg) + .await + .unwrap(); + sessions::revoke_session(&pool, &session.id).await.unwrap(); + let validated = sessions::validate_session(&pool, &raw_token, &sec_cfg) + .await + .unwrap(); + assert!(validated.is_none()); + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// PAT Token Lifecycle Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_pat_creation_and_validation() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "pat_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let (token, raw_pat) = tokens::create_token(&pool, &user.id, "my-token", &sec_cfg) + .await + .unwrap(); + assert!(raw_pat.starts_with("nx9_pat_")); + + let validated = tokens::validate_token(&pool, &raw_pat) + .await + .unwrap() + .unwrap(); + assert_eq!(validated.id, token.id); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_pat_revocation() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "pat_rev_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let (token, raw_pat) = tokens::create_token(&pool, &user.id, "my-token", &sec_cfg) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + token_repo::revoke(&mut tx, &token.id).await.unwrap(); + tx.commit().await.unwrap(); + + let validated = tokens::validate_token(&pool, &raw_pat).await.unwrap(); + assert!(validated.is_none()); + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// API Endpoints Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_api_health_endpoint() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool, config); + let app = api::router::build(state); + + let req = Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_version_endpoint() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool, config); + let app = api::router::build(state); + + let req = Request::builder() + .uri("/version") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_login_success() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + let password = "super_secure_passphrase_123"; + let _ = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "login_ok_user", + password, + ) + .await + .unwrap(); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"login_ok_user","password":"super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(cookie.contains("nx9_session=")); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_login_invalid_password() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + let _ = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "login_err_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"login_err_user","password":"wrong_password"}"#, + )) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_login_invalid_user() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"does_not_exist","password":"some_password"}"#, + )) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_me_authenticated() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + let _ = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "me_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"me_user","password":"super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + let req = Request::builder() + .uri("/api/v1/auth/me") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_me_unauthenticated() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool, config); + let app = api::router::build(state); + + let req = Request::builder() + .uri("/api/v1/auth/me") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_logout_success() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + let _ = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "logout_user", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"logout_user","password":"super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/logout") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // Profile should now fail + let req = Request::builder() + .uri("/api/v1/auth/me") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_list_users_viewer_forbidden() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create a viewer user + let viewer = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_viewer", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &viewer.id, "viewer") + .await + .unwrap(); + + // Login as viewer + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_viewer","password":"super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Attempt list users (requires users:create) + let req = Request::builder() + .uri("/api/v1/users") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_list_users_admin_allowed() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create an admin user + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_list", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_list","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // List users (requires users:create, which admin has) + let req = Request::builder() + .uri("/api/v1/users") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_create_user_unauthorized() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool, config); + let app = api::router::build(state); + + // Call user creation without cookie + let req = Request::builder() + .method("POST") + .uri("/api/v1/users") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"new_user","password":"some_password"}"#, + )) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_create_user_authorized() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create admin + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_creator", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_creator","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Create user via API + let req = Request::builder() + .method("POST") + .uri("/api/v1/users") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_created_user","password":"super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_delete_user_self_forbidden() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create admin + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_del_self", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_del_self","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Delete self (should fail) + let req = Request::builder() + .method("DELETE") + .uri(format!("/api/v1/users/{}", admin.id)) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_delete_user_success() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create admin + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_deleter", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Create standard user to delete + let target = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "delete_target", + "super_secure_passphrase_123", + ) + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_deleter","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Delete target user + let req = Request::builder() + .method("DELETE") + .uri(format!("/api/v1/users/{}", target.id)) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_token_creation_and_listing() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create admin + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_token", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_token","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Create token + let req = Request::builder() + .method("POST") + .uri("/api/v1/tokens") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"name":"test-api-token"}"#)) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // List tokens + let req = Request::builder() + .uri("/api/v1/tokens") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_api_token_revocation() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config.clone()); + let app = api::router::build(state); + + // Create admin + let admin = identity_users::create_user( + &pool, + &config.security, + Tenant::DEFAULT_ID, + "api_admin_tok_rev", + "super_secure_admin_passphrase_123", + ) + .await + .unwrap(); + identity_roles::assign_role(&pool, &admin.id, "admin") + .await + .unwrap(); + + // Login as admin + let req = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username":"api_admin_tok_rev","password":"super_secure_admin_passphrase_123"}"#, + )) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let cookie = res + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + + // Create token + let req = Request::builder() + .method("POST") + .uri("/api/v1/tokens") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"name":"test-rev-token"}"#)) + .unwrap(); + let res = app.clone().oneshot(req).await.unwrap(); + let body: Value = + serde_json::from_slice(&res.into_body().collect().await.unwrap().to_bytes()).unwrap(); + let token_id = body + .get("token") + .unwrap() + .get("id") + .unwrap() + .as_str() + .unwrap(); + + // Revoke token + let req = Request::builder() + .method("DELETE") + .uri(format!("/api/v1/tokens/{}", token_id)) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::OK); + teardown_test_db(db_path).await; +} diff --git a/tests/migration_compatibility.rs b/tests/migration_compatibility.rs new file mode 100644 index 0000000..f304e97 --- /dev/null +++ b/tests/migration_compatibility.rs @@ -0,0 +1,181 @@ +use nx9_auth::db::{self, models::Tenant, repository::roles as role_repo}; + +async fn setup_test_db() -> (sqlx::SqlitePool, String) { + let db_id = uuid::Uuid::new_v4().to_string(); + let db_path = format!("target/migration_{}.db", db_id); + let pool = db::create_pool(&db_path) + .await + .expect("Failed to create test pool"); + (pool, db_path) +} + +async fn teardown_test_db(path: String) { + let _ = std::fs::remove_file(path); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1: Fresh Database -> Migrate -> Success +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_migration_scenario_1_fresh() { + let (pool, db_path) = setup_test_db().await; + + // Run all migrations + let res = db::run_migrations(&pool).await; + assert!(res.is_ok(), "Fresh migration failed: {:?}", res); + + // Verify default tables exist + for table in &[ + "tenants", + "users", + "roles", + "permissions", + "sessions", + "audit_logs", + "_sqlx_migrations", + ] { + let exists: Option<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .bind(table) + .fetch_optional(&pool) + .await + .unwrap(); + assert!(exists.is_some(), "Table '{}' was not created", table); + } + + // Verify default tenant and admin/viewer roles exist + let tenant_exists = sqlx::query("SELECT 1 FROM tenants WHERE id = ?") + .bind(Tenant::DEFAULT_ID) + .fetch_optional(&pool) + .await + .unwrap() + .is_some(); + assert!(tenant_exists); + + let admin_role = role_repo::find_by_name(&pool, "admin").await.unwrap(); + assert!(admin_role.is_some()); + + let viewer_role = role_repo::find_by_name(&pool, "viewer").await.unwrap(); + assert!(viewer_role.is_some()); + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 2: Database at migration N -> Migrate to N+1 -> Success +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_migration_scenario_2_incremental() { + let (pool, db_path) = setup_test_db().await; + + let migrator = sqlx::migrate!("src/db/migrations"); + let all_migrations = &migrator.migrations; + assert!( + all_migrations.len() >= 3, + "Expected at least 3 migrations to test incremental scenario" + ); + + // 1. Manually create the _sqlx_migrations table + sqlx::query( + r#" + CREATE TABLE _sqlx_migrations ( + version INTEGER PRIMARY KEY, + description TEXT NOT NULL, + installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN NOT NULL, + checksum BLOB NOT NULL, + execution_time INTEGER NOT NULL + ) + "#, + ) + .execute(&pool) + .await + .unwrap(); + + // 2. Manually apply the first 2 migrations (N = 2) + for migration in all_migrations.iter().take(2) { + let sql: &'static str = Box::leak(migration.sql.as_ref().to_string().into_boxed_str()); + // Run SQL query directly + sqlx::query(sql).execute(&pool).await.unwrap(); + + // Record it in _sqlx_migrations so SQLx knows it is applied + sqlx::query( + r#" + INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) + VALUES (?, ?, 1, ?, 0) + "#, + ) + .bind(migration.version) + .bind(migration.description.as_ref()) + .bind(migration.checksum.as_ref()) + .execute(&pool) + .await + .unwrap(); + } + + // 3. Now run the SQLx Migrator to migrate to N+1 (and all remaining ones) + let res = migrator.run(&pool).await; + assert!(res.is_ok(), "Incremental migration failed: {:?}", res); + + // Verify all tables are successfully created + for table in &["tenants", "users", "roles", "permissions"] { + let exists: Option<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .bind(table) + .fetch_optional(&pool) + .await + .unwrap(); + assert!( + exists.is_some(), + "Table '{}' was not created incrementally", + table + ); + } + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3: Run Migrations Twice -> Idempotent +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_migration_scenario_3_idempotence() { + let (pool, db_path) = setup_test_db().await; + + // First run + let res1 = db::run_migrations(&pool).await; + assert!(res1.is_ok()); + + // Second run + let res2 = db::run_migrations(&pool).await; + assert!( + res2.is_ok(), + "Second migration run failed (idempotency issue): {:?}", + res2 + ); + + // Verify default tenant and roles count didn't duplicate + let tenant_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tenants WHERE id = ?") + .bind(Tenant::DEFAULT_ID) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(tenant_count.0, 1, "Default tenant was duplicated!"); + + let admin_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(admin_count.0, 1, "Admin role was duplicated!"); + + let viewer_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'viewer'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(viewer_count.0, 1, "Viewer role was duplicated!"); + + teardown_test_db(db_path).await; +} diff --git a/tests/security_test.rs b/tests/security_test.rs new file mode 100644 index 0000000..4222498 --- /dev/null +++ b/tests/security_test.rs @@ -0,0 +1,585 @@ +use axum::{ + body::Body, + http::{Request, StatusCode, header}, +}; +use nx9_auth::{ + api, + config::{Config, SecurityConfig}, + db::{ + self, + models::Tenant, + repository::{roles as role_repo, tokens as token_repo, users as user_repo}, + }, + identity::{roles as identity_roles, users as identity_users}, + security::{passwords, sessions, tokens}, + state::AppState, +}; +use serde_json::Value; +use tower::ServiceExt; + +async fn setup_test_db() -> (sqlx::SqlitePool, String) { + let db_id = uuid::Uuid::new_v4().to_string(); + let db_path = format!("target/security_{}.db", db_id); + let pool = db::create_pool(&db_path) + .await + .expect("Failed to create test pool"); + db::run_migrations(&pool) + .await + .expect("Failed to run test migrations"); + (pool, db_path) +} + +async fn teardown_test_db(path: String) { + let _ = std::fs::remove_file(path); +} + +fn test_security_config() -> SecurityConfig { + SecurityConfig { + session_ttl_hours: 24, + session_absolute_ttl_days: 30, + token_ttl_days: 365, + argon2_memory: 4096, // low cost for fast tests + argon2_iterations: 1, + argon2_parallelism: 1, + } +} + +fn test_config(db_path: String) -> Config { + Config { + server: nx9_auth::config::ServerConfig { + host: "127.0.0.1".to_string(), + port: 8656, + }, + database: nx9_auth::config::DatabaseConfig { path: db_path }, + security: test_security_config(), + audit: nx9_auth::config::AuditConfig { enabled: true }, + ..Default::default() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Password & Token Leakage Verification +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_security_no_plaintext_passwords_in_db() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let password = "super_secret_special_pass_123456"; + + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "leak_test_user", + password, + None, + None, + None, + ) + .await + .unwrap(); + + // Query the raw database row and verify the plaintext password is not in the row + let row: (String,) = sqlx::query_as("SELECT password_hash FROM users WHERE id = ?") + .bind(&user.id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!row.0.contains(password)); + assert_ne!(row.0, password); + + // Grep/search the entire users table for the plaintext password string + let matches: Vec<(String,)> = + sqlx::query_as("SELECT id FROM users WHERE password_hash LIKE ? OR username LIKE ?") + .bind(format!("%{}%", password)) + .bind(format!("%{}%", password)) + .fetch_all(&pool) + .await + .unwrap(); + assert!(matches.is_empty()); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_no_plaintext_tokens_in_db() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "token_leak_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + let (token, raw_pat) = + tokens::create_token(&pool, &user.id, "my_pat", &sec_cfg, None, None, None) + .await + .unwrap(); + + // Check token_hash in db + let row: (String,) = sqlx::query_as("SELECT token_hash FROM api_tokens WHERE id = ?") + .bind(&token.id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!raw_pat.is_empty()); + assert!(!row.0.contains(&raw_pat)); + assert_ne!(row.0, raw_pat); + + // Search table + let matches: Vec<(String,)> = + sqlx::query_as("SELECT id FROM api_tokens WHERE token_hash LIKE ? OR name LIKE ?") + .bind(format!("%{}%", raw_pat)) + .bind(format!("%{}%", raw_pat)) + .fetch_all(&pool) + .await + .unwrap(); + assert!(matches.is_empty()); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_no_plaintext_sessions_in_db() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "session_leak_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + let (session, raw_token) = + sessions::create_session(&pool, &user.id, Some("127.0.0.1"), Some("UA"), &sec_cfg) + .await + .unwrap(); + + let row: (String,) = sqlx::query_as("SELECT token_hash FROM sessions WHERE id = ?") + .bind(&session.id) + .fetch_one(&pool) + .await + .unwrap(); + + assert!(!raw_token.is_empty()); + assert!(!row.0.contains(&raw_token)); + assert_ne!(row.0, raw_token); + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. User Enumeration Protection +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_security_user_enumeration_payload_match() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config); + let app = api::router::build(state); + + // Scenario A: Non-existent user + let req_non_existent = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username": "non_existent_user_123", "password": "some_random_password"}"#, + )) + .unwrap(); + let res_non_existent = app.clone().oneshot(req_non_existent).await.unwrap(); + assert_eq!(res_non_existent.status(), StatusCode::UNAUTHORIZED); + + let body_bytes = axum::body::to_bytes(res_non_existent.into_body(), 2048) + .await + .unwrap(); + let json_non_existent: Value = serde_json::from_slice(&body_bytes).unwrap(); + + // Scenario B: Existent user, wrong password + let sec_cfg = test_security_config(); + let _user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "existent_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + let req_wrong_password = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username": "existent_user", "password": "wrong_password_abc"}"#, + )) + .unwrap(); + let res_wrong_password = app.oneshot(req_wrong_password).await.unwrap(); + assert_eq!(res_wrong_password.status(), StatusCode::UNAUTHORIZED); + + let body_bytes_wrong = axum::body::to_bytes(res_wrong_password.into_body(), 2048) + .await + .unwrap(); + let json_wrong_password: Value = serde_json::from_slice(&body_bytes_wrong).unwrap(); + + // Compare JSON outputs and check format + let expected = serde_json::json!({ + "error": "invalid credentials", + "code": "unauthorized" + }); + + assert_eq!(json_non_existent, expected); + assert_eq!(json_wrong_password, expected); + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Session Revocation +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_security_session_revocation_lifecycle() { + let (pool, db_path) = setup_test_db().await; + let config = test_config(db_path.clone()); + let state = AppState::new(pool.clone(), config); + let app = api::router::build(state); + + let sec_cfg = test_security_config(); + let _user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "session_lifecycle_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + // 1. Login to get cookie + let req_login = Request::builder() + .method("POST") + .uri("/api/v1/auth/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"username": "session_lifecycle_user", "password": "super_secure_passphrase_123"}"#, + )) + .unwrap(); + let res_login = app.clone().oneshot(req_login).await.unwrap(); + assert_eq!(res_login.status(), StatusCode::OK); + + let cookie_header = res_login + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + let cookie_value = cookie_header.split(';').next().unwrap(); // e.g. nx9_session=abc... + + // 2. Validate GET /api/v1/auth/me works + let req_me = Request::builder() + .method("GET") + .uri("/api/v1/auth/me") + .header(header::COOKIE, cookie_value) + .body(Body::empty()) + .unwrap(); + let res_me = app.clone().oneshot(req_me).await.unwrap(); + assert_eq!(res_me.status(), StatusCode::OK); + + // 3. Logout to revoke session + let req_logout = Request::builder() + .method("POST") + .uri("/api/v1/auth/logout") + .header(header::COOKIE, cookie_value) + .body(Body::empty()) + .unwrap(); + let res_logout = app.clone().oneshot(req_logout).await.unwrap(); + assert_eq!(res_logout.status(), StatusCode::OK); + + // 4. Try reuse session cookie -> must get 401 + let req_me_revoked = Request::builder() + .method("GET") + .uri("/api/v1/auth/me") + .header(header::COOKIE, cookie_value) + .body(Body::empty()) + .unwrap(); + let res_me_revoked = app.oneshot(req_me_revoked).await.unwrap(); + assert_eq!(res_me_revoked.status(), StatusCode::UNAUTHORIZED); + + teardown_test_db(db_path).await; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Transaction Rollback Verification +// ───────────────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn test_security_transaction_rollback_on_audit_failure_create_user() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + + // Trigger Foreign Key constraint violation by passing non-existent audit actor ID + let bad_actor_id = "non_existent_user_id_trigger_rollback"; + let res = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "rollback_user", + "super_secure_passphrase_123", + Some(bad_actor_id), + None, + None, + ) + .await; + + // Must return Database/Constraint error + assert!(res.is_err()); + + // Verify user was NOT created in the database due to transaction rollback + let user_in_db = user_repo::find_by_username(&pool, "rollback_user") + .await + .unwrap(); + assert!(user_in_db.is_none()); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_transaction_rollback_on_audit_failure_reset_password() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + + // Create user successfully + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "rollback_pwd_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + let original_hash = user.password_hash.clone(); + + // Try resetting password but with a bad audit actor id to trigger FK violation + let bad_actor_id = "non_existent_actor_id"; + let res = identity_users::reset_password( + &pool, + &sec_cfg, + &user.id, + "new_super_secure_passphrase_123456", + Some(bad_actor_id), + None, + None, + ) + .await; + + assert!(res.is_err()); + + // Verify password hash in db is still the original one (rolled back) + let user_after = user_repo::find_by_id(&pool, &user.id) + .await + .unwrap() + .unwrap(); + assert_eq!(user_after.password_hash, original_hash); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_transaction_rollback_on_audit_failure_assign_role() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "rollback_role_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + // Try to assign admin role but fail on audit step + let bad_actor_id = "non_existent_actor_id"; + let res = + identity_roles::assign_role(&pool, &user.id, "admin", Some(bad_actor_id), None, None).await; + + assert!(res.is_err()); + + // Verify role was not assigned + let user_roles = role_repo::list_for_user(&pool, &user.id).await.unwrap(); + assert!(user_roles.is_empty()); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_transaction_rollback_on_audit_failure_create_token() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "rollback_tok_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + // Try to create token but fail on audit log FK violation + let bad_actor_id = "non_existent_actor_id"; + let res = tokens::create_token( + &pool, + &user.id, + "my-pat-token", + &sec_cfg, + Some(bad_actor_id), + None, + None, + ) + .await; + + assert!(res.is_err()); + + // Verify no tokens were created for the user + let user_tokens = token_repo::list_for_user(&pool, &user.id).await.unwrap(); + assert!(user_tokens.is_empty()); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_assign_non_existent_role_fails() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let user = identity_users::create_user( + &pool, + &sec_cfg, + Tenant::DEFAULT_ID, + "no_role_user", + "super_secure_passphrase_123", + None, + None, + None, + ) + .await + .unwrap(); + + let res = + identity_roles::assign_role(&pool, &user.id, "non_existent_role_name", None, None, None) + .await; + assert!(res.is_err()); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_create_token_non_existent_user_fails() { + let (pool, db_path) = setup_test_db().await; + let sec_cfg = test_security_config(); + let res = tokens::create_token( + &pool, + "non_existent_user_id", + "my-token", + &sec_cfg, + None, + None, + None, + ) + .await; + assert!(res.is_err()); + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_service_account_audit_lifecycle() { + let (pool, db_path) = setup_test_db().await; + let name = "my-service-account"; + let desc = Some("A test description"); + + // 1. Create service account + let sa = nx9_auth::identity::service_accounts::create( + &pool, + Tenant::DEFAULT_ID, + name, + desc, + None, + None, + None, + ) + .await + .unwrap(); + assert_eq!(sa.name, name); + assert_eq!(sa.description.as_deref(), desc); + assert!(sa.enabled); + + // 2. Disable service account + let res_disable = + nx9_auth::identity::service_accounts::set_enabled(&pool, &sa.id, false, None, None, None) + .await; + assert!(res_disable.is_ok()); + + let sa_disabled = nx9_auth::identity::service_accounts::list(&pool, Tenant::DEFAULT_ID) + .await + .unwrap() + .into_iter() + .find(|x| x.id == sa.id) + .unwrap(); + assert!(!sa_disabled.enabled); + + teardown_test_db(db_path).await; +} + +#[tokio::test] +async fn test_security_verify_dummy_execution() { + let sec_cfg = test_security_config(); + let res = passwords::verify_dummy(&sec_cfg); + assert!(res.is_ok()); +} + +#[tokio::test] +async fn test_security_invalid_password_strength_admin() { + // Admin password needs to be at least 12 characters + let res = passwords::validate_password_strength("too_short_1", true); + assert!(res.is_err()); + + let res_ok = passwords::validate_password_strength("long_enough_admin_pass_123", true); + assert!(res_ok.is_ok()); +}