From 6c39e0bfbf4fa96c99c55eb3a950a5126df97548 Mon Sep 17 00:00:00 2001 From: Sunil Thakare Date: Sun, 21 Jun 2026 20:05:29 +0530 Subject: [PATCH] Initial public release v0.1.0 --- .github/workflows/rust.yml | 41 + .gitignore | 39 + Cargo.lock | 2517 +++++++++++++++++ Cargo.toml | 82 + Dockerfile | 52 + README.md | 138 + build.rs | 41 + compose.casaos.yml | 57 + config.example.toml | 40 + deploy.sh | 171 ++ docker-compose.yml | 26 + docs/BACKUPS.md | 86 + docs/BENCHMARKS.md | 50 + docs/DEPLOYMENT.md | 94 + docs/DOCKER.md | 113 + docs/INTEGRATION_BZOD.md | 113 + nx9-auth.service | 47 + scripts/release.sh | 56 + src/api/auth.rs | 233 ++ src/api/health.rs | 7 + src/api/mod.rs | 6 + src/api/router.rs | 51 + src/api/tokens.rs | 128 + src/api/users.rs | 166 ++ src/api/version.rs | 15 + src/audit/audit.rs | 84 + src/audit/mod.rs | 3 + src/bin/bench.rs | 178 ++ src/cli/mod.rs | 1020 +++++++ src/config/mod.rs | 272 ++ src/db/migrations/0001_create_tenants.sql | 10 + src/db/migrations/0002_create_users.sql | 16 + .../migrations/0003_create_user_profiles.sql | 7 + src/db/migrations/0004_create_roles.sql | 5 + src/db/migrations/0005_create_permissions.sql | 5 + .../0006_create_role_permissions.sql | 7 + src/db/migrations/0007_create_user_roles.sql | 7 + src/db/migrations/0008_create_sessions.sql | 15 + src/db/migrations/0009_create_api_tokens.sql | 13 + .../0010_create_service_accounts.sql | 12 + .../migrations/0011_create_applications.sql | 12 + src/db/migrations/0012_create_audit_logs.sql | 20 + .../migrations/0013_seed_default_tenant.sql | 4 + .../0014_seed_roles_and_permissions.sql | 35 + src/db/mod.rs | 68 + src/db/models/api_token.rs | 20 + src/db/models/application.rs | 13 + src/db/models/audit_log.rs | 55 + src/db/models/mod.rs | 20 + src/db/models/permission.rs | 9 + src/db/models/role.rs | 9 + src/db/models/service_account.rs | 13 + src/db/models/session.rs | 23 + src/db/models/tenant.rs | 17 + src/db/models/user.rs | 68 + src/db/repository/applications.rs | 60 + src/db/repository/audit.rs | 49 + src/db/repository/mod.rs | 8 + src/db/repository/permissions.rs | 41 + src/db/repository/roles.rs | 70 + src/db/repository/service_accounts.rs | 59 + src/db/repository/sessions.rs | 79 + src/db/repository/tokens.rs | 74 + src/db/repository/users.rs | 121 + src/error/mod.rs | 104 + src/identity/applications.rs | 31 + src/identity/mod.rs | 5 + src/identity/permissions.rs | 37 + src/identity/roles.rs | 104 + src/identity/service_accounts.rs | 88 + src/identity/users.rs | 187 ++ src/lib.rs | 10 + src/main.rs | 154 + src/middleware/audit.rs | 50 + src/middleware/auth.rs | 96 + src/middleware/mod.rs | 3 + src/middleware/permissions.rs | 12 + src/security/mod.rs | 6 + src/security/passwords.rs | 143 + src/security/rate_limit.rs | 191 ++ src/security/sessions.rs | 131 + src/security/tokens.rs | 165 ++ src/state.rs | 23 + tests/cli_test.rs | 287 ++ tests/integration_test.rs | 1304 +++++++++ tests/migration_compatibility.rs | 181 ++ tests/security_test.rs | 585 ++++ 87 files changed, 10867 insertions(+) create mode 100644 .github/workflows/rust.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 build.rs create mode 100644 compose.casaos.yml create mode 100644 config.example.toml create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 docs/BACKUPS.md create mode 100644 docs/BENCHMARKS.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 docs/DOCKER.md create mode 100644 docs/INTEGRATION_BZOD.md create mode 100644 nx9-auth.service create mode 100755 scripts/release.sh create mode 100644 src/api/auth.rs create mode 100644 src/api/health.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/router.rs create mode 100644 src/api/tokens.rs create mode 100644 src/api/users.rs create mode 100644 src/api/version.rs create mode 100644 src/audit/audit.rs create mode 100644 src/audit/mod.rs create mode 100644 src/bin/bench.rs create mode 100644 src/cli/mod.rs create mode 100644 src/config/mod.rs create mode 100644 src/db/migrations/0001_create_tenants.sql create mode 100644 src/db/migrations/0002_create_users.sql create mode 100644 src/db/migrations/0003_create_user_profiles.sql create mode 100644 src/db/migrations/0004_create_roles.sql create mode 100644 src/db/migrations/0005_create_permissions.sql create mode 100644 src/db/migrations/0006_create_role_permissions.sql create mode 100644 src/db/migrations/0007_create_user_roles.sql create mode 100644 src/db/migrations/0008_create_sessions.sql create mode 100644 src/db/migrations/0009_create_api_tokens.sql create mode 100644 src/db/migrations/0010_create_service_accounts.sql create mode 100644 src/db/migrations/0011_create_applications.sql create mode 100644 src/db/migrations/0012_create_audit_logs.sql create mode 100644 src/db/migrations/0013_seed_default_tenant.sql create mode 100644 src/db/migrations/0014_seed_roles_and_permissions.sql create mode 100644 src/db/mod.rs create mode 100644 src/db/models/api_token.rs create mode 100644 src/db/models/application.rs create mode 100644 src/db/models/audit_log.rs create mode 100644 src/db/models/mod.rs create mode 100644 src/db/models/permission.rs create mode 100644 src/db/models/role.rs create mode 100644 src/db/models/service_account.rs create mode 100644 src/db/models/session.rs create mode 100644 src/db/models/tenant.rs create mode 100644 src/db/models/user.rs create mode 100644 src/db/repository/applications.rs create mode 100644 src/db/repository/audit.rs create mode 100644 src/db/repository/mod.rs create mode 100644 src/db/repository/permissions.rs create mode 100644 src/db/repository/roles.rs create mode 100644 src/db/repository/service_accounts.rs create mode 100644 src/db/repository/sessions.rs create mode 100644 src/db/repository/tokens.rs create mode 100644 src/db/repository/users.rs create mode 100644 src/error/mod.rs create mode 100644 src/identity/applications.rs create mode 100644 src/identity/mod.rs create mode 100644 src/identity/permissions.rs create mode 100644 src/identity/roles.rs create mode 100644 src/identity/service_accounts.rs create mode 100644 src/identity/users.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/middleware/audit.rs create mode 100644 src/middleware/auth.rs create mode 100644 src/middleware/mod.rs create mode 100644 src/middleware/permissions.rs create mode 100644 src/security/mod.rs create mode 100644 src/security/passwords.rs create mode 100644 src/security/rate_limit.rs create mode 100644 src/security/sessions.rs create mode 100644 src/security/tokens.rs create mode 100644 src/state.rs create mode 100644 tests/cli_test.rs create mode 100644 tests/integration_test.rs create mode 100644 tests/migration_compatibility.rs create mode 100644 tests/security_test.rs 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()); +}