Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579f415a9f | ||
|
|
9c410cc717 | ||
|
|
312c78dbbb | ||
|
|
b25a015898 | ||
|
|
f2f615b456 | ||
|
|
526c4aa157 | ||
|
|
3d14061795 | ||
|
|
a969f9c571 | ||
|
|
dc5417334b | ||
|
|
4c697e9adf | ||
|
|
b6798e7a7c | ||
|
|
36903d2125 | ||
|
|
0134ff6a50 | ||
|
|
0010f2cfb8 | ||
|
|
5c1340c9cb | ||
|
|
af68b43ae0 | ||
|
|
112ce77891 |
No files matched your search
+86
-16
@@ -1,22 +1,92 @@
|
||||
- uses: actions/checkout@v4
|
||||
name: Rust CI
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
components: rustfmt, clippy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: full
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
jobs:
|
||||
test:
|
||||
name: Rust CI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
- name: Tests
|
||||
run: cargo test --all
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
rust:
|
||||
- stable
|
||||
|
||||
- name: Release build
|
||||
run: cargo build --release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Environment Information
|
||||
run: |
|
||||
echo "=== Git ==="
|
||||
git rev-parse HEAD
|
||||
git log --oneline -1
|
||||
git status
|
||||
|
||||
echo
|
||||
echo "=== Rust ==="
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
echo
|
||||
echo "=== System ==="
|
||||
uname -a
|
||||
|
||||
echo
|
||||
echo "=== Environment ==="
|
||||
env | sort
|
||||
|
||||
- name: Verify formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace --all-features --all-targets -- -D warnings
|
||||
|
||||
- name: Build
|
||||
run: cargo build --workspace --all-features --verbose
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace --all-features --verbose -- --nocapture
|
||||
|
||||
- name: Build release
|
||||
run: cargo build --release --workspace --all-features
|
||||
|
||||
- name: Upload test databases
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-databases
|
||||
path: target/*.db
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload logs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: target-directory
|
||||
path: target
|
||||
if-no-files-found: ignore
|
||||
Generated
+60
-67
@@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -25,9 +25,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -114,9 +114,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.42"
|
||||
version = "0.4.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac"
|
||||
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -274,9 +274,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.5"
|
||||
version = "1.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
|
||||
checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
@@ -324,9 +324,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.3.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -365,9 +365,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.4"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -375,9 +375,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.2"
|
||||
version = "4.6.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
@@ -432,15 +432,6 @@ 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"
|
||||
@@ -593,13 +584,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -610,9 +601,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -645,20 +636,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
@@ -834,9 +824,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hdrhistogram"
|
||||
version = "7.5.4"
|
||||
version = "7.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
|
||||
checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"num-traits",
|
||||
@@ -874,9 +864,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -919,9 +909,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
@@ -987,12 +977,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
@@ -1000,9 +991,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -1013,9 +1004,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
@@ -1027,15 +1018,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.1.2"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
@@ -1047,15 +1038,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.1.2"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.1.1"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -1079,9 +1070,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
@@ -1248,7 +1239,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nx9-auth"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1265,6 +1256,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"subtle",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
@@ -1274,6 +1266,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -1453,9 +1446,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.16"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
|
||||
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
@@ -1933,9 +1926,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.54"
|
||||
version = "0.3.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"num-conv",
|
||||
@@ -2005,13 +1998,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2458,18 +2451,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.55"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+3
-2
@@ -1,8 +1,7 @@
|
||||
[package]
|
||||
name = "nx9-auth"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
authors = ["NX9 Team","Sunil Thakare"]
|
||||
description = "Lightweight self-hosted IAM service for the NX9 ecosystem"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -73,6 +72,8 @@ dashmap = "6.0"
|
||||
|
||||
# Utilities
|
||||
hex = "0.4"
|
||||
url = "2.5"
|
||||
subtle = "2.6"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# NX9-Auth Dual License
|
||||
|
||||
NX9-Auth is distributed under the terms of both the **MIT License** and the **Apache License (Version 2.0)**.
|
||||
|
||||
You may choose, at your option, to use this software under the terms of either:
|
||||
- The MIT License ([LICENSE-MIT](LICENSE-MIT))
|
||||
- The Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
|
||||
|
||||
## Contributions
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NX9 Team & Sunil Thakare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
**Enterprise Identity & Access Management (IAM)**
|
||||
|
||||
*Self-Hosted • Privacy-First • Pure Rust • Single Binary • Dual Database Engine*
|
||||
*Self-Hosted • Privacy-First • Pure Rust • Single Binary • SQLite & PostgreSQL*
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://www.rust-lang.org/)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
@@ -19,21 +19,88 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**nx9-auth** is a production-grade, self-hosted Identity & Access Management (IAM) server built entirely in **Rust**. It provides multi-tenant user authentication, Role-Based Access Control (RBAC), Personal Access Tokens (PATs), OAuth2 service accounts, active session management, full audit logging, an enterprise graceful shutdown runtime lifecycle, and an embedded WebAssembly (WASM) administrative UI.
|
||||
**nx9-auth** is a self-hosted Identity & Access Management (IAM) server built entirely in **Rust**. It provides centralized authentication, multi-tenancy, fine-grained Role-Based Access Control (RBAC), Personal Access Tokens (PATs), service accounts, application registration credentials, active session management, and append-only audit logging.
|
||||
|
||||
`nx9-auth` compiles into a single standalone binary containing both the Axum REST API backend and the embedded Dioxus WASM frontend, backed by a database-agnostic provider supporting both **SQLite** and **PostgreSQL**.
|
||||
The server uses **Axum**, **Tokio**, and **SQLx**, with repository implementations for both embedded **SQLite** and external **PostgreSQL** deployments. Its administration interface is built with **Dioxus 0.6** and compiled to **WebAssembly (WASM)**, requiring no Node.js or npm runtime/build chain for the application architecture.
|
||||
|
||||
The runtime lifecycle and Application Registration Credentials subsystems have completed dedicated production-hardening passes covering deterministic shutdown, live signal escalation, transactional credential operations, secret handling, authorization boundaries, redirect URI validation, and regression testing.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Unified Enterprise Runtime Lifecycle**: Atomic 8-state lifecycle machine (`Initializing` → `Starting` → `Running` → `Draining` → `StoppingWorkers` → `ExecutingHooks` → `ClosingResources` → `Stopped`), `CancellationToken` propagation, `JoinSet` worker management, prioritized shutdown hooks, and destructor-safe Unix signal escalation.
|
||||
- **Dual Database Engine**: Native support for SQLite and enterprise PostgreSQL with 100% repository parity and runtime connection pool ownership.
|
||||
- **Enterprise Security Model**: Argon2id password hashing, BLAKE3 token/session hashing, rate-limiting, CSP, HSTS, and non-enumerating authentication.
|
||||
- **Multi-Tenant & RBAC**: Tenant isolation, fine-grained permission matrix, role assignments, and organizational user groups.
|
||||
- **Personal Access Tokens & Service Accounts**: Machine-to-machine authentication with automatic prefix tracking and instant revocation.
|
||||
- **Embedded WebAssembly UI**: Dioxus-powered administration dashboard with `#boot-loader` lifecycle management.
|
||||
- **Comprehensive CLI Tooling**: Automated `init`, `doctor`, `migrate`, `backup`, `restore`, and user management commands.
|
||||
- **Deterministic Runtime Lifecycle**: Atomic 8-state lifecycle (`Initializing` → `Starting` → `Running` → `Draining` → `StoppingWorkers` → `ExecutingHooks` → `ClosingResources` → `Stopped`) with graph-validated transitions, cancellation propagation, supervised worker shutdown, HTTP draining, prioritized shutdown hooks, and live forced escalation on a second Unix termination signal.
|
||||
- **SQLite & PostgreSQL Support**: Shared repository abstraction with backend-specific migrations and repository implementations for embedded SQLite and external PostgreSQL deployments.
|
||||
- **Security-Oriented Authentication**: Argon2id password hashing, BLAKE3 credential/token digests, constant-time credential comparison, rate limiting, non-enumerating authentication failures, secret redaction, and security response headers.
|
||||
- **Multi-Tenant RBAC**: Tenant-aware identities, fine-grained permissions, role assignments, and organizational user groups.
|
||||
- **Personal Access Tokens & Service Accounts**: Credentials for API and machine-to-machine access with hashed-at-rest secrets and revocation support.
|
||||
- **Application Registration Credentials**: Immutable server-generated Client IDs, one-time Client Secret disclosure, BLAKE3 secret hashing, constant-time verification, secret rotation, redirect URI metadata, scopes, and dedicated `applications:manage` authorization.
|
||||
- **Transactional Credential Integrity**: Application creation and Client Secret rotation are committed atomically with their audit records; audit failure rolls back the associated credential operation.
|
||||
- **Strict Application Identity**: Application authentication uses `client_id` only; editable application slugs are never accepted as credential identities.
|
||||
- **Redirect URI Policy**: Registered redirect URIs are structurally validated. HTTPS is supported generally; HTTP is restricted to localhost/loopback development destinations. Fragments, userinfo credentials, unsupported schemes, excessive URI counts, and oversized entries are rejected.
|
||||
- **Embedded WebAssembly Administration UI**: Dioxus-powered administration interface compiled to WASM without a Node.js/React frontend stack.
|
||||
- **Structured Auditability**: Security-sensitive lifecycle and identity operations are audit logged while plaintext passwords, Client Secrets, tokens, and stored credential hashes are excluded from audit metadata.
|
||||
- **CLI Tooling**: Command-line workflows for initialization, diagnostics, migration, backup/restore, server operation, and identity administration.
|
||||
|
||||
---
|
||||
|
||||
## Application Registration Credentials
|
||||
|
||||
Applications are registered with a stable public identity and a high-entropy secret:
|
||||
|
||||
```text
|
||||
Client ID: nx9_app_<32 lowercase hex characters>
|
||||
Client Secret: nx9_secret_<64 lowercase hex characters>
|
||||
```
|
||||
|
||||
The **Client ID** is immutable and safe to identify an application. The **Client Secret** is disclosed only when the application is created or its secret is explicitly rotated.
|
||||
|
||||
Plaintext Client Secrets are never persisted. NX9-Auth stores a BLAKE3 digest and performs credential comparison using constant-time byte comparison. Creation and secret rotation responses are treated as one-time secret disclosure operations and use `Cache-Control: no-store`.
|
||||
|
||||
Existing applications upgraded from earlier schemas receive a stable Client ID. Applications without previously configured credentials can establish credentials through explicit secret rotation.
|
||||
|
||||
> **Protocol boundary:** Application credentials, redirect URIs, and scopes form the application registration layer. Redirect URIs are registration metadata intended to become security-enforced redirect destinations when OAuth2/OIDC protocol handlers are implemented. This registration subsystem does not by itself claim complete OAuth2/OIDC grant-flow support.
|
||||
|
||||
### Application user membership
|
||||
|
||||
Registered applications can be assigned existing NX9-Auth users (same-tenant only). Membership is independent of application client credentials and of global RBAC:
|
||||
|
||||
| Concern | Role |
|
||||
| --- | --- |
|
||||
| Application credentials | Authenticate the registered application itself (`client_id` + `client_secret`) |
|
||||
| Application membership | Assign existing human users to an application (`owner` / `admin` / `member` metadata) |
|
||||
| Global RBAC | Authoritative admin authorization (`applications:manage`, roles, permissions) |
|
||||
|
||||
Membership APIs (all require `applications:manage`):
|
||||
|
||||
- `GET/POST /api/v1/applications/:id/members`
|
||||
- `PATCH/DELETE /api/v1/applications/:id/members/:user_id`
|
||||
- `GET /api/v1/users/:id/applications`
|
||||
|
||||
Membership roles do **not** grant `applications:manage` or any other global permission. Removing membership revokes application assignment only; it does not delete the user account.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Lifecycle
|
||||
|
||||
NX9-Auth uses an explicit lifecycle graph:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Initializing
|
||||
Initializing --> Starting: Build Runtime
|
||||
Starting --> Running: Start Services
|
||||
Running --> Draining: Begin Shutdown
|
||||
Draining --> StoppingWorkers: HTTP Drain Completes or Is Force-Aborted
|
||||
StoppingWorkers --> ExecutingHooks: Workers Terminated
|
||||
ExecutingHooks --> ClosingResources: Hooks Complete
|
||||
ClosingResources --> Stopped: Resources Closed
|
||||
Stopped --> [*]
|
||||
```
|
||||
|
||||
The first `SIGINT` or `SIGTERM` initiates graceful shutdown, transitions the runtime into `Draining`, and begins HTTP request draining. Signal monitoring remains active throughout shutdown. A second termination signal escalates shutdown immediately, allowing HTTP draining and blocked worker waits to be curtailed rather than consuming the remaining graceful deadline.
|
||||
|
||||
Worker groups receive cancellation concurrently and operate under a shared global shutdown budget. Shutdown hooks execute deterministically by priority, with hooks at the same priority retaining registration order.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +110,7 @@
|
||||
# Initialize application directory, configuration, and default administrator
|
||||
nx9-auth init
|
||||
|
||||
# Verify installation & system health
|
||||
# Verify installation and system health
|
||||
nx9-auth doctor
|
||||
|
||||
# Start server
|
||||
@@ -54,7 +121,7 @@ nx9-auth serve
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure `config.toml` or set environment variables:
|
||||
Configure `config.toml` or use the supported environment-variable configuration:
|
||||
|
||||
```toml
|
||||
[server]
|
||||
@@ -67,7 +134,7 @@ cookie_secure = false
|
||||
# SQLite URL or file path:
|
||||
url = "sqlite://./data/auth.db?mode=rwc"
|
||||
|
||||
# Or enterprise PostgreSQL:
|
||||
# Or PostgreSQL:
|
||||
# url = "postgres://user:password@localhost:5432/nx9auth"
|
||||
|
||||
max_connections = 20
|
||||
@@ -81,11 +148,56 @@ graceful_timeout_secs = 30
|
||||
force_timeout_secs = 35
|
||||
```
|
||||
|
||||
For production deployments, terminate TLS appropriately, use secure cookies, protect configuration and database credentials, and apply deployment-specific filesystem and network permissions.
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
NX9-Auth applies layered controls rather than relying on any single authentication mechanism:
|
||||
|
||||
| Area | Control |
|
||||
|---|---|
|
||||
| Passwords | Argon2id password hashing |
|
||||
| Application secrets | BLAKE3 digest at rest |
|
||||
| Credential comparison | `subtle::ConstantTimeEq` |
|
||||
| Authentication failures | Non-enumerating unauthorized responses |
|
||||
| Application mutations | Dedicated `applications:manage` permission |
|
||||
| Application creation | Transactional application + audit insertion |
|
||||
| Secret rotation | Transactional secret update + audit insertion |
|
||||
| Secret disclosure | One-time response; never returned by list/GET operations |
|
||||
| Redirect URIs | Structural and scheme-policy validation |
|
||||
| HTTP responses | Security headers and no-store handling for secret responses |
|
||||
| Audit metadata | Secret and credential-hash redaction |
|
||||
|
||||
Security controls documented here describe implemented mechanisms and should not be interpreted as a substitute for deployment-specific threat modelling, security review, or external audit.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
The runtime lifecycle and Application Registration Credentials hardening scopes are covered by workspace unit, integration, migration, security, and acceptance tests.
|
||||
|
||||
The verification gates used for these scopes are:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --all-features
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace --all-features
|
||||
cargo build --release
|
||||
cargo check --manifest-path ui/Cargo.toml --target wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
At the documented hardening checkpoint, the workspace test suite completed with **97 tests passed and 0 failed**. Test counts and execution times are verification-run observations rather than performance guarantees.
|
||||
|
||||
---
|
||||
|
||||
## Documentation Index
|
||||
|
||||
- [Runtime Lifecycle & Graceful Shutdown](docs/runtime-lifecycle.md)
|
||||
- [System Architecture](docs/ARCHITECTURE.md)
|
||||
- [Runtime Lifecycle & Graceful Shutdown](docs/RUNTIME_LIFECYCLE.md)
|
||||
- [Security Architecture](docs/SECURITY.md)
|
||||
- [Release Notes](RELEASE_NOTES.md)
|
||||
- [Authentication Model](docs/AUTHENTICATION.md)
|
||||
- [Backup & Disaster Recovery](docs/BACKUPS.md)
|
||||
@@ -98,10 +210,19 @@ force_timeout_secs = 35
|
||||
|
||||
---
|
||||
|
||||
## Project Status
|
||||
|
||||
The **Runtime Lifecycle** and **Application Registration Credentials** hardening scopes documented for the current release are complete.
|
||||
|
||||
Future OAuth2/OIDC protocol handlers, additional authentication protocols, deployment hardening, or architectural changes should be introduced as separately scoped work with corresponding migrations, security review, and regression tests.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Dual-licensed under either of:
|
||||
- Apache License, Version 2.0 ([LICENSE](LICENSE) or http://www.apache.org/licenses/LICENSE-2.0)
|
||||
- MIT License ([LICENSE](LICENSE) or http://opensource.org/licenses/MIT)
|
||||
|
||||
- Apache License, Version 2.0 ([LICENSE](LICENSE) or <http://www.apache.org/licenses/LICENSE-2.0>)
|
||||
- MIT License ([LICENSE](LICENSE) or <http://opensource.org/licenses/MIT>)
|
||||
|
||||
at your option.
|
||||
@@ -0,0 +1,370 @@
|
||||
# 1. System Overview
|
||||
|
||||
NX9-Auth is a self-hosted Identity and Access Management (IAM) server written in pure Rust. It provides centralized authentication, fine-grained Role-Based Access Control (RBAC), multi-tenancy, service account management, personal access tokens (PATs), and append-only audit logging.
|
||||
|
||||
The system is architected as a stateless HTTP server paired with a zero-JavaScript-framework WebAssembly (WASM) administration user interface. Executing natively on Linux operating systems without external memory caches, JavaScript runtimes, or third-party web frameworks, NX9-Auth achieves low memory consumption, high throughput, zero garbage collection pauses, and operational simplicity.
|
||||
|
||||
Supported deployment models include single-binary installations, systemd-managed services, containerized workloads, and reverse-proxy setups using embedded SQLite or external PostgreSQL database engines.
|
||||
|
||||
---
|
||||
|
||||
# 2. Design Philosophy
|
||||
|
||||
NX9-Auth adheres to seven core design tenets:
|
||||
|
||||
- **Linux-First**: Native optimization for Linux operating systems, systemd process supervision, standard UNIX signal handling, and POSIX filesystem standards.
|
||||
- **Privacy-First**: Zero external telemetry, phone-home calls, or third-party tracking. All identity records remain strictly under local operator control.
|
||||
- **Self-Hosted**: Distributed as 100% Free and Open Source Software (FOSS) dual-licensed under Apache 2.0 and MIT.
|
||||
- **Rust-Native**: End-to-end type safety, compile-time memory safety, and thread concurrency guarantees across both server and WebAssembly client binaries.
|
||||
- **Security by Default**: OWASP-aligned response headers, memory-hard Argon2id key derivation, BLAKE3 token hashing at rest, non-enumerating error responses, and strict Content Security Policies.
|
||||
- **Zero Node.js Runtime**: No JavaScript runtime dependencies, npm build chains, or external frontend node packages. The administrative interface is compiled from Rust directly to WebAssembly.
|
||||
- **Operational Simplicity**: Single-binary deployment capability with embedded or external SQL databases. Zero mandatory external cache or message broker sidecars.
|
||||
|
||||
---
|
||||
|
||||
# 3. Architectural Principles
|
||||
|
||||
The internal architecture is guided by structural design patterns:
|
||||
|
||||
- **Layer Separation**: Downward-only dependency flow from entry points to persistence drivers.
|
||||
- **Repository Pattern**: Pluggable storage providers implementing unified async trait interfaces.
|
||||
- **Dependency Inversion**: Service and handler layers depend on trait abstractions rather than concrete database drivers.
|
||||
- **Stateless APIs**: Authentication state is encapsulated in cryptographically hashed session cookies or Bearer tokens, eliminating sticky-session server dependencies.
|
||||
- **Explicit Errors**: Strongly typed error enumerations mapping internal failures to standard HTTP status codes without leaking sensitive stack traces.
|
||||
- **Fail-Fast Startup**: Early validation of configuration paths, database connectivity, and encryption parameters before opening listening sockets.
|
||||
- **Graceful Shutdown**: Signal-driven, multi-stage shutdown sequence ensuring background worker completion, audit log flushing, and pool draining.
|
||||
|
||||
---
|
||||
|
||||
# 4. Data & Multi-Tenancy Architecture
|
||||
|
||||
### Option-A Single-Tenant User Ownership
|
||||
NX9-Auth models user ownership via **Option-A Single-Tenant Ownership**:
|
||||
- Every user belongs to exactly one tenant (`users.tenant_id NOT NULL REFERENCES tenants(id)`).
|
||||
- Uniqueness invariant: `UNIQUE (tenant_id, username)`.
|
||||
- Tenant reassignment is transactionally atomic (`reassign_user_tenant_with_audit`):
|
||||
1. Executes inside a single write transaction.
|
||||
2. PostgreSQL uses `SELECT ... FOR UPDATE` row locking; SQLite uses write transactions and conditional `WHERE tenant_id = expected` updates.
|
||||
3. Reassignment to the user's current tenant is a defined no-op returning `Ok(())` without writing false audit logs.
|
||||
4. Database mutation (`UPDATE users.tenant_id`) and audit log insertion (`user.tenant_reassigned` with `from_tenant_id` and `to_tenant_id`) commit together; audit log failures trigger automatic database rollback.
|
||||
|
||||
### Application Membership vs Global RBAC
|
||||
- **Application Membership**: Users are assigned to applications within their home tenant only (`ApplicationMember`). Membership roles (`owner`/`admin`/`member`) are application-scoped metadata.
|
||||
- **Global RBAC**: Platform authorization is governed strictly by global roles, permissions, and groups (`users.tenant_id`). Application membership roles never leak into or modify global RBAC.
|
||||
- **Application Membership Mutations**: Add, role update, enable/disable, and removal operations execute as single database transactions; failure to write audit logs automatically rolls back the membership mutation.
|
||||
|
||||
### Global Slugs Registry & Lifecycle
|
||||
- `global_slugs` table enforces global uniqueness across tenant, application, and resource slugs.
|
||||
- Immutable UUID identities remain canonical; slugs serve as human-readable routing aliases.
|
||||
|
||||
---
|
||||
|
||||
# 5. Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Core Language**: Rust (2024 Edition)
|
||||
- **Async Runtime**: Tokio
|
||||
- **HTTP Routing**: Axum and Tower
|
||||
- **Database Engine**: SQLx (supporting SQLite and PostgreSQL)
|
||||
|
||||
### Frontend
|
||||
- **UI Framework**: Dioxus (WebAssembly compilation target)
|
||||
- **WASM Interop**: wasm-bindgen
|
||||
- **HTTP Client**: Reqwest (configured for credentialed WebAssembly fetch operations)
|
||||
- **Browser Storage**: gloo-storage
|
||||
|
||||
### Cryptography & Security
|
||||
- **Password Hashing**: Argon2id
|
||||
- **Token Hashing**: BLAKE3
|
||||
- **Rate Limiting**: DashMap (lock-free in-memory tracking)
|
||||
|
||||
---
|
||||
|
||||
# 5. Runtime Architecture
|
||||
|
||||
The server runtime isolates process lifecycle management from business domain logic.
|
||||
|
||||
### Components
|
||||
- **Application**: Core container holding global state, connection pools, state trackers, and worker managers.
|
||||
- **Builder**: Assembles configuration, initializes database providers, applies database migrations, and binds the router.
|
||||
- **Lifecycle**: Manages application state transitions from initialization to termination.
|
||||
- **State**: Lock-free atomic state machine enforcing valid lifecycle transitions.
|
||||
- **Workers**: Supervises background asynchronous tasks such as expired session pruning and audit log flushing.
|
||||
- **Signals**: Asynchronous signal listener intercepting SIGINT and SIGTERM.
|
||||
- **Hooks**: Maintains prioritized cleanup routines executed during graceful shutdown.
|
||||
- **Metrics**: Tracks runtime uptime, active connections, and worker states.
|
||||
- **Shutdown**: Manages prioritized shutdown hooks and completion timeouts.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Start]) --> Initializing[Initializing]
|
||||
Initializing -->|Build application runtime| Starting[Starting]
|
||||
Starting -->|Bind listener and serve| Running[Running]
|
||||
Running -->|Signal received| Draining[Draining]
|
||||
Draining -->|Stop background workers| StoppingWorkers[Stopping Workers]
|
||||
StoppingWorkers -->|Execute shutdown hooks| ExecutingHooks[Executing Hooks]
|
||||
ExecutingHooks -->|Close database pools| ClosingResources[Closing Resources]
|
||||
ClosingResources --> Stopped[Stopped]
|
||||
Stopped --> EndState([End])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. Request Lifecycle
|
||||
|
||||
Every incoming HTTP request traverses a structured, layered processing pipeline.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Client[Client Request] --> TCP[TCP Listener]
|
||||
TCP --> Router[Axum HTTP Router]
|
||||
Router --> SecHeaders[Security Headers Middleware]
|
||||
SecHeaders --> TracingMW[Tracing and Request ID]
|
||||
TracingMW --> Sanitizer[Query String Credential Sanitizer]
|
||||
Sanitizer --> AuthExtractor[Authentication Extractor]
|
||||
AuthExtractor --> GuardCheck{Authorized}
|
||||
GuardCheck -->|No| ErrResp[HTTP 401 or 403 Response] --> Client
|
||||
GuardCheck -->|Yes| Handler[API Route Handler]
|
||||
Handler --> Service[Domain Service Layer]
|
||||
Service --> RepoTrait[Repository Interface]
|
||||
RepoTrait --> DBImpl[Database Provider]
|
||||
DBImpl --> DB[(Database Engine)]
|
||||
DB --> DBImpl --> RepoTrait --> Service --> Handler
|
||||
Handler --> Response[JSON Response or SPA Assets] --> Client
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 7. Repository Architecture
|
||||
|
||||
NX9-Auth decouples persistence logic from domain services using trait abstractions. This ensures API handlers remain agnostic of the underlying storage backend.
|
||||
|
||||
### Layer Hierarchy
|
||||
1. **Traits**: Define high-level database access contracts.
|
||||
2. **SQLite Implementation**: Embedded storage driver using Write-Ahead Logging for high concurrency.
|
||||
3. **PostgreSQL Implementation**: Enterprise storage driver for external multi-node deployments.
|
||||
4. **Service Layer**: Coordinates business logic, transactions, and audit trail records across repositories.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class UserRepository {
|
||||
+find_by_id(id)
|
||||
+find_by_username(username)
|
||||
+create(user)
|
||||
+update(user)
|
||||
+delete(id)
|
||||
}
|
||||
class SqliteUserRepository {
|
||||
+find_by_id(id)
|
||||
+create(user)
|
||||
}
|
||||
class PostgresUserRepository {
|
||||
+find_by_id(id)
|
||||
+create(user)
|
||||
}
|
||||
class AuthService {
|
||||
+authenticate(credentials)
|
||||
}
|
||||
UserRepository <|.. SqliteUserRepository
|
||||
UserRepository <|.. PostgresUserRepository
|
||||
AuthService --> UserRepository
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 8. Authentication
|
||||
|
||||
NX9-Auth supports dual-mode authentication, accommodating both browser environments and automated API clients.
|
||||
|
||||
### Authentication Credentials and Identifiers
|
||||
- **Login Handler**: Accepts JSON credential payloads and validates passwords using Argon2id.
|
||||
- **Password Verification**: Memory-hard verification with constant-time dummy delays on invalid usernames to neutralize timing side-channels.
|
||||
- **Sessions**: Short-lived opaque session tokens issued upon login and stored as BLAKE3 hashes at rest.
|
||||
- **Refresh Tokens**: Opaque refresh tokens used to obtain new session tokens without re-entering credentials.
|
||||
- **Personal Access Tokens (PAT)**: Long-lived tokens generated for automated API integrations.
|
||||
- **Service Accounts**: Non-human identities bound to specific tenants and permission scopes.
|
||||
- **Cookies**: HttpOnly, SameSite-protected cookies holding session identifiers for browser clients.
|
||||
- **Bearer Tokens**: Authorization header tokens for API consumers and WebAssembly applications.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
AuthRequest[Incoming HTTP Request] --> RouteType{Request Path}
|
||||
RouteType -->|Login Route| LoginHandler[Login Handler]
|
||||
LoginHandler --> VerifyPassword[Verify Password via Argon2id]
|
||||
VerifyPassword -->|Invalid| TimingMitigation[Execute Dummy Hash Delay] --> Return401[Return HTTP 401]
|
||||
VerifyPassword -->|Valid| RevokeSessions[Revoke Active User Sessions]
|
||||
RevokeSessions --> GenerateTokens[Generate Opaque Tokens]
|
||||
GenerateTokens --> HashTokens[Compute BLAKE3 Hashes]
|
||||
HashTokens --> SaveDB[Store Hashes in Database]
|
||||
SaveDB --> IssueAuth[Issue HttpOnly Cookie and Bearer Token] --> AuthSuccess[Authentication Success]
|
||||
RouteType -->|Protected API Route| ExtractAuth[Extract Authentication Context]
|
||||
ExtractAuth --> CheckCookie{Cookie Present}
|
||||
CheckCookie -->|Yes| ValidateCookie[BLAKE3 Lookup in Sessions Table]
|
||||
ValidateCookie -->|Valid| ExtractUserCookie[Find Active User] --> SessionAuth[Authenticated Session]
|
||||
CheckCookie -->|No| CheckHeader{Authorization Header Present}
|
||||
CheckHeader -->|Yes| TokenPrefix{Token Prefix}
|
||||
TokenPrefix -->|PAT Prefix| ValidatePAT[BLAKE3 Lookup in PAT Table] --> ExtractUserPAT[Find Active User] --> PATAuth[Authenticated Token]
|
||||
TokenPrefix -->|Session Prefix| ValidateSession[BLAKE3 Lookup in Sessions Table] --> ExtractUserSession[Find Active User] --> SessionAuth
|
||||
CheckHeader -->|No| Return401
|
||||
ValidateCookie -->|Invalid| CheckHeader
|
||||
ValidatePAT -->|Invalid| Return401
|
||||
ValidateSession -->|Invalid| Return401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. Authorization
|
||||
|
||||
NX9-Auth implements a hierarchical Role-Based Access Control (RBAC) authorization model.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User or Service Account] --> UserRoles[Assigned Roles]
|
||||
UserRoles --> RolePermissions[Role Permissions]
|
||||
RolePermissions --> GlobalPermissions[Effective Permission Set]
|
||||
Request[API Endpoint Request] --> RequiredPerm[Required Permission Scope]
|
||||
RequiredPerm --> AccessEvaluator{Permission Granted}
|
||||
GlobalPermissions --> AccessEvaluator
|
||||
AccessEvaluator -->|Yes| Allow[Execute Handler]
|
||||
AccessEvaluator -->|No| Deny[HTTP 403 Forbidden]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 10. Security
|
||||
|
||||
NX9-Auth enforces a defense-in-depth security posture across all subsystems:
|
||||
|
||||
- **Argon2id Key Derivation**: Memory-hard password hashing parameters (m=19456 KiB, t=2, p=1).
|
||||
- **BLAKE3 Cryptographic Hashing**: High-speed cryptographic hashing for storing session tokens, refresh tokens, and personal access tokens at rest.
|
||||
- **Secure Cookie Attributes**: HttpOnly, SameSite=Lax, and Secure flag enforcement in production environments.
|
||||
- **Content Security Policy (CSP)**: Strict header policy prohibiting inline script execution while allowing WebAssembly evaluation.
|
||||
- **HTTP Strict Transport Security (HSTS)**: Transport security header enforcement when running under secure configurations.
|
||||
- **Audit Logging**: Append-only, tamper-evident audit trail capturing actor, target, severity, IP address, and user agent.
|
||||
- **Rate Limiting**: Lock-free in-memory IP tracking with automatic lockout penalties upon consecutive failure thresholds.
|
||||
- **Credential Sanitization**: Fallback routes intercept and strip query strings containing credentials, returning HTTP 303 redirects to clean paths.
|
||||
|
||||
---
|
||||
|
||||
# 11. Multi-tenancy
|
||||
|
||||
Resources are hierarchically isolated to enforce strict multi-tenant data boundaries.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
System[NX9-Auth System] --> TenantA[Tenant A]
|
||||
System --> TenantB[Tenant B]
|
||||
TenantA --> UsersA[Users and Service Accounts]
|
||||
TenantA --> GroupsA[Groups]
|
||||
TenantA --> AppsA[Applications]
|
||||
GroupsA --> RolesA[Roles]
|
||||
AppsA --> ResourcesA[Resources and Tokens]
|
||||
TenantB --> UsersB[Users and Service Accounts]
|
||||
TenantB --> GroupsB[Groups]
|
||||
TenantB --> AppsB[Applications]
|
||||
```
|
||||
|
||||
### Data Isolation Rules
|
||||
- Database queries for tenant-scoped entities enforce explicit tenant filters.
|
||||
- Service accounts and applications are strictly bound to their parent tenant identifier.
|
||||
|
||||
---
|
||||
|
||||
# 12. Frontend
|
||||
|
||||
The administrative user interface is implemented as a WebAssembly Single Page Application (SPA) built with Dioxus.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser[Web Browser] --> IndexHTML[Index HTML]
|
||||
IndexHTML --> BootJS[Boot Script]
|
||||
BootJS --> WASMModule[WASM Module]
|
||||
WASMModule --> VDOM[Virtual DOM Engine]
|
||||
VDOM --> SignalState[Signal State]
|
||||
SignalState --> Router[Dioxus Router]
|
||||
Router --> EventSystem[Dual Event Interceptors]
|
||||
EventSystem --> FormSubmit[Form Submit Listener]
|
||||
EventSystem --> ButtonClick[Button Click Listener]
|
||||
FormSubmit --> PreventDefault[Prevent Default Event]
|
||||
ButtonClick --> PreventDefault
|
||||
PreventDefault --> WASMFetch[Reqwest WASM Fetch]
|
||||
WASMFetch --> BackendAPI[Axum REST API]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 13. Configuration
|
||||
|
||||
NX9-Auth manages system parameters through a hierarchical configuration system.
|
||||
|
||||
### Configuration Precedence Order
|
||||
1. Command Line Interface (CLI) Arguments
|
||||
2. Environment Variables
|
||||
3. Configuration Files (TOML format)
|
||||
4. Built-in Defaults
|
||||
|
||||
Startup execution validates configuration parameters immediately. If configuration paths, database URIs, or security options fail validation, process startup halts with explicit error messages before network ports are bound.
|
||||
|
||||
---
|
||||
|
||||
# 14. Deployment
|
||||
|
||||
NX9-Auth is deployed as a single self-contained executable on Linux systems, supervised by systemd and situated behind a reverse proxy.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Internet[Client Traffic] --> ReverseProxy[Reverse Proxy Caddy or Nginx]
|
||||
ReverseProxy --> AppService[NX9-Auth Process Systemd Supervised]
|
||||
AppService --> SQLite[SQLite Database Engine]
|
||||
AppService --> Postgres[PostgreSQL Database Engine]
|
||||
```
|
||||
|
||||
### Process Supervision Overview
|
||||
Systemd handles process lifetime, automatic restarts, resource limits, and security sandboxing (such as restricting filesystem access and disabling privilege escalation). Standard deployment files reside in `deploy/systemd/nx9-auth.service`.
|
||||
|
||||
---
|
||||
|
||||
# 15. Repository Layout
|
||||
|
||||
```text
|
||||
/
|
||||
├── Cargo.toml # Primary Cargo workspace configuration
|
||||
├── Cargo.lock # Dependency version lockfile
|
||||
├── build.rs # Static asset embedding build script
|
||||
├── README.md # Project landing documentation
|
||||
├── LICENSE # Dual license declaration
|
||||
├── LICENSE-MIT # MIT License text
|
||||
├── LICENSE-APACHE # Apache 2.0 License text
|
||||
├── src/ # Core backend source files
|
||||
│ ├── main.rs # Entry point and subcommand router
|
||||
│ ├── lib.rs # Library root exporting domain modules
|
||||
│ ├── api/ # REST API handlers and endpoint routes
|
||||
│ ├── audit/ # Audit trail service and data structures
|
||||
│ ├── cli/ # CLI command parsing logic
|
||||
│ ├── config/ # Configuration file parsing and environment logic
|
||||
│ ├── db/ # SQLx abstractions and migration files
|
||||
│ ├── error/ # Application error types
|
||||
│ ├── identity/ # User, role, group, and tenant domain services
|
||||
│ ├── middleware/ # Security header and authentication middlewares
|
||||
│ ├── runtime/ # Lifecycle, state machine, and signal handlers
|
||||
│ └── security/ # Password hashing, token hashing, and rate limiting
|
||||
├── ui/ # WebAssembly frontend crate (Dioxus)
|
||||
├── tests/ # Integration and security test suites
|
||||
├── scripts/ # Maintenance and build helper scripts
|
||||
├── deploy/ # Systemd service files and deployment templates
|
||||
└── docs/ # Architecture documents and technical specifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 16. Future Roadmap
|
||||
|
||||
Planned future architectural extensions include:
|
||||
|
||||
- **OpenID Connect (OIDC) & OAuth2 Server**: Native implementation enabling NX9-Auth to function as a full OIDC Authorization Server.
|
||||
- **SAML 2.0 Support**: Enterprise federation support for identity provider integrations.
|
||||
- **SCIM 2.0 Provisioning**: System for Cross-domain Identity Management interface for automated user synchronization.
|
||||
- **Directory Integration**: LDAP and Active Directory authentication capability.
|
||||
- **Multi-Factor Authentication (MFA)**: TOTP (RFC 6238) and WebAuthn / FIDO2 passkey support.
|
||||
- **High-Availability Clustering**: Distributed session cache synchronization across multi-region server nodes.
|
||||
- **Observability Exporters**: Native OpenTelemetry metrics and tracing integration for Prometheus and Grafana monitoring stacks.
|
||||
@@ -1,82 +0,0 @@
|
||||
# NX9-Auth v0.3.0 Recovery Report
|
||||
|
||||
## Timeline
|
||||
|
||||
- **INC-001 (Accidental Data Loss)**: Untracked development files deleted via `git clean -xfd` and `cargo clean`.
|
||||
- **Forensic Phase**: Git fsck, dangling commits, and local caches inspected; architectural documentation recovered.
|
||||
- **INC-002 (Incomplete Runtime Refactor)**: Modular runtime subsystem reconstructed (`src/runtime/*`), but `Application::start()` returned immediately without awaiting the Axum HTTP server.
|
||||
- **INC-003 (GET Submission & CSP Violation Audit)**:
|
||||
- Form attribute `action="javascript:void(0)"` was evaluated by browser CSP engines as an inline script URL, causing Chromium/Firefox to block WASM event execution under strict CSP `script-src 'self' 'wasm-unsafe-eval'`.
|
||||
- Resolution: Replaced `javascript:void(0)` with clean `action="/api/v1/auth/login"`.
|
||||
- **Recovery & Stabilization Execution**:
|
||||
- Reimplemented `Application::start()` HTTP server binding and signal-driven graceful shutdown.
|
||||
- Reimplemented dependency assembly in `ApplicationBuilder`.
|
||||
- Rebuilt WASM UI package (`./scripts/build-ui.sh`) with strict CSP compliance (zero inline scripts, zero `javascript:` URIs).
|
||||
- Hardened server-side `serve_ui` fallback to sanitize & redirect (HTTP 303) any GET request containing query parameters (`password=`, `username=`).
|
||||
- Added integration tests verifying `GET /login?username=...&password=...` is redirected and sanitized (HTTP 303), and `GET /api/v1/auth/login` returns HTTP 405 Method Not Allowed.
|
||||
- Hardened OWASP security headers (`Cache-Control: no-store`, CSP, HSTS).
|
||||
- Restored complete documentation suite (`runtime-lifecycle.md`, `AUTHENTICATION.md`, `SECURITY.md`, `DEPLOYMENT.md`, `CHANGELOG.md`, `RELEASE_NOTES.md`, `RECOVERY_REPORT.md`).
|
||||
- Executed automated test suite and live binary verification.
|
||||
|
||||
## Incident Summary
|
||||
|
||||
During active development on v0.3.0, uncommitted runtime files were lost due to an uncommitted state cleanup (`git clean -xfd`). A modular refactor successfully resolved compilation, but server execution exited immediately due to an un-awaited Tokio server handle. Furthermore, a UI form attribute `action="javascript:void(0)"` triggered browser CSP inline-script blocks, preventing WASM authentication handlers from executing.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
1. **INC-002 (Server Exit)**: `Application::start()` performed state transitions from `Starting` to `Running` and immediately returned `Ok(())` without initializing the `axum::serve` future or binding a TCP listener.
|
||||
2. **INC-003 (CSP Inline Script Block)**: Browsers interpret `javascript:` URL targets in HTML attributes as inline script executions. Under strict CSP (`script-src 'self' 'wasm-unsafe-eval'`), `action="javascript:void(0)"` was blocked by the browser CSP filter, preventing Dioxus WASM event delegation and blocking the `api::login` network request. Replacing `action` with `/api/v1/auth/login` completely eliminated all `javascript:` inline URIs.
|
||||
|
||||
## Lost Components
|
||||
|
||||
- `src/runtime/application.rs` server future execution logic.
|
||||
- `src/runtime/builder.rs` dependency assembly integration.
|
||||
- Dedicated runtime lifecycle test suite (`tests/runtime_lifecycle_test.rs`).
|
||||
- Technical lifecycle documentation (`docs/runtime-lifecycle.md`).
|
||||
- Architectural decision records (ADR-0001) and security policy documentation (`docs/SECURITY.md`).
|
||||
|
||||
## Recovered Components
|
||||
|
||||
- `Config` file parsing and search path mechanisms.
|
||||
- Database providers (`SqliteProvider`, `PostgresProvider`) and repository abstractions.
|
||||
- All CLI subcommands (`serve`, `migrate`, `doctor`, `create-admin`, `create-user`, `list-users`, `disable-user`, `enable-user`, `reset-password`, `create-token`, `revoke-token`, `init`, `backup`, `restore`, `config-path`, `show-user`, `show-token`).
|
||||
- Full API router with all 17 feature areas (`auth`, `users`, `roles`, `permissions`, `tenants`, `groups`, `applications`, `service_accounts`, `sessions`, `tokens`, `audit`, `profile`, `dashboard`, `health`, `version`, `ui`, `settings`).
|
||||
|
||||
## Reimplemented Components
|
||||
|
||||
- **Runtime Application Container**: Complete implementation of `Application` with `TcpListener` binding and graceful shutdown on SIGINT/SIGTERM.
|
||||
- **State Machine Integration**: Deterministic state transitions (`Initializing` -> `Starting` -> `Running` -> `Draining` -> `StoppingWorkers` -> `ExecutingHooks` -> `ClosingResources` -> `Stopped`).
|
||||
- **CSP-Compliant UI Login Form**: Replaced `action="javascript:void(0)"` with `action="/api/v1/auth/login"` in `ui/src/pages/auth/mod.rs` to guarantee zero CSP inline script violations.
|
||||
- **Server Query Credential Sanitizer**: Updated `src/api/ui.rs` `serve_ui` to detect any GET request containing `password=`, `username=`, or `secret=` and immediately sanitize via HTTP 303 See Other redirect to the clean path.
|
||||
- **OWASP Header Hardening**: Added `Cache-Control: no-store` to security headers middleware.
|
||||
|
||||
## Validation Results
|
||||
|
||||
| Test Category | Command | Result |
|
||||
| :--- | :--- | :--- |
|
||||
| Code Formatting | `cargo fmt --all -- --check` | PASS |
|
||||
| Workspace Check | `cargo check --workspace --all-targets --all-features` | PASS (0 errors) |
|
||||
| Linter Verification | `cargo clippy --workspace --all-targets --all-features -- -D warnings` | PASS (0 warnings) |
|
||||
| Unit & Integration Tests | `cargo test --workspace --all-features` | PASS (**77/77 tests**) |
|
||||
| CSP Compliance | Browser Console Audit | **0 CSP Violations** (Strict `'self' 'wasm-unsafe-eval'`) |
|
||||
| GET Login Rejection (API) | `GET /api/v1/auth/login?username=...` | **405 Method Not Allowed** |
|
||||
| GET Login Sanitization (UI) | `GET /login?username=...&password=...` | **303 See Other -> /login** |
|
||||
| POST Login (API & UI) | `POST /api/v1/auth/login` | **200 OK (JSON Body)** |
|
||||
| Auth Status Check | `GET /api/v1/auth/me` | **401 (Anon) / 200 (Authed)** |
|
||||
| Health Endpoint | `curl http://127.0.0.1:8655/health` | HTTP 200 OK |
|
||||
| Version Endpoint | `curl http://127.0.0.1:8655/version` | HTTP 200 OK |
|
||||
| System Diagnostics | `nx9-auth doctor` | Doctor result: OK |
|
||||
|
||||
## Remaining Known Issues
|
||||
|
||||
None. All compilation issues, runtime termination defects, CSP inline script violations, GET form submission leaks, security header requirements, and missing documentation items have been completely resolved.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
1. **Modular Runtime Architecture**: Retained lock-free atomic state machine (`AtomicRuntimeState`) for zero-mutex-contention lifecycle tracking.
|
||||
2. **Layered Separation**: Preserved downward dependency flow (`CLI` -> `Runtime` -> `Application` -> `HTTP Router` -> `Services` -> `Repositories` -> `Database`).
|
||||
3. **OWASP & CSP Compliance**: Retained strict CSP (`script-src 'self' 'wasm-unsafe-eval'`) without `'unsafe-inline'`, enforced POST-only login with JSON payloads, zero credentials in URLs or logs, dual-layer GET query parameter sanitization, and strict security response headers.
|
||||
|
||||
## Release Approval
|
||||
|
||||
The NX9-Auth v0.3.0 codebase satisfies all functional, architectural, security, and quality requirements. The release is approved for tagging and production deployment.
|
||||
File diff suppressed because it is too large.
Load diff
+30
-4
@@ -22,12 +22,38 @@ NX9-Auth is designed with a **security-first, privacy-first, zero-trust** archit
|
||||
- `Permissions-Policy: accelerometer=(), camera=(), geolocation=(), ...`
|
||||
- `Strict-Transport-Security: max-age=63072000; includeSubDomains` (when `cookie_secure` / production is enabled)
|
||||
|
||||
## Audit Logging Security
|
||||
## Application Credentials & Client Authentication
|
||||
|
||||
- **Client ID & Client Secret**: Applications registered in NX9-Auth receive an immutable, server-generated `client_id` (`nx9_app_<32 lowercase hex chars>`) and high-entropy CSPRNG `client_secret` (`nx9_secret_<64 lowercase hex chars>`).
|
||||
- **One-Time Secret Disclosure**: Plaintext client secrets are disclosed **exactly once** upon initial application creation and explicit secret rotation. Responses containing plaintext secrets include `Cache-Control: no-store` headers.
|
||||
- **BLAKE3 Secret Hashing**: Only BLAKE3 cryptographic digests (`[u8; 32]`) are persisted in database records. Plaintext secrets are never stored, logged, serialized into GET/list API responses, or stored in browser persistence.
|
||||
- **Constant-Time Raw Byte Verification**: Verification hashes supplied credentials to a 32-byte BLAKE3 digest and constant-time compares bytes against the stored 32-byte digest. To prevent timing side-channel attacks for unknown or uncredentialed applications, a dummy BLAKE3 comparison path is executed before returning non-enumerating `401 Unauthorized` errors.
|
||||
- **Secret Rotation**: Administrator rotation immediately invalidates the previous client secret hash and generates a new secret.
|
||||
- **Dedicated Permissions**: Application mutations (`create`, `update`, `rotate_secret`, `enable_disable`, `delete`) require the `applications:manage` permission.
|
||||
|
||||
## Tenant Reassignment Safety & Audit Atomicity
|
||||
|
||||
- **Option-A Tenant Isolation**: Users belong strictly to one tenant (`users.tenant_id`). Cross-tenant operations strictly check boundaries.
|
||||
- **Transactional Atomicity**: Tenant reassignment (`reassign_user_tenant_with_audit`) executes inside a single database transaction. Database update (`users.tenant_id`) and audit log record insertion (`user.tenant_reassigned` with `from_tenant_id` and `to_tenant_id`) commit together. If audit log insertion fails, database changes roll back completely.
|
||||
- **No-Op Reassignment**: Reassignment to the user's current tenant is a defined no-op that emits no audit log and avoids unnecessary database mutations.
|
||||
- **Concurrency & Locking Protection**: PostgreSQL uses `SELECT ... FOR UPDATE` row locking; SQLite uses write transactions and conditional updates (`WHERE tenant_id = expected`).
|
||||
- **Last-Admin Protection**: System administrator reassignment away from the default tenant is rejected if `count_admins() <= 1`.
|
||||
|
||||
## Application Membership Security & Audit Atomicity
|
||||
|
||||
- **Same-Tenant Enforcement**: Application membership requires user and application to share the exact same `tenant_id`. Cross-tenant membership is rejected.
|
||||
- **Transactional Atomicity**: Application membership mutations (add, role update, enable/disable, remove) execute in single transactions with audit log insertions; audit failure automatically rolls back the membership change.
|
||||
- **Strict Protocol Boundary**: Application authentication accepts only `client_id` + `client_secret`. Editable application slugs are never accepted as credential identities.
|
||||
|
||||
## Audit Logging Security & Export
|
||||
|
||||
Audit logs record critical identity lifecycle events while strictly redacting sensitive fields:
|
||||
- **Recorded Events**: Login success/failure, logout, password change, user creation/deletion, API token issuance/revocation, role/permission assignments.
|
||||
- **Redaction Rules**: Plaintext passwords, password hashes, bearer tokens, refresh tokens, session secrets, and `Authorization` headers are **never** logged under any circumstances.
|
||||
- **Recorded Events**: Login success/failure, logout, password change, user creation/deletion, tenant reassignment, API token issuance/revocation, application creation/secret rotation/membership modification, role/permission assignments.
|
||||
- **Redaction Rules**: Plaintext passwords, password hashes, bearer tokens, refresh tokens, client secrets, client secret hashes, session secrets, and `Authorization` headers are **never** logged under any circumstances.
|
||||
- **Success/Failure Filters**: Server-side derived success/failure filtering is based on audit action and severity semantics; success is not persisted as a database column.
|
||||
- **Exact Resource Activity**: Resource activity filters use exact `resource_type` and `resource_id` predicates; generic text search remains separate.
|
||||
- **Bounded CSV Export**: Audit log CSV export uses server-side audit search APIs bounded to a maximum of 5,000 records matching active filters and preserves the same `audit:view` authorization as the normal audit endpoint, with RFC-4180 field escaping.
|
||||
|
||||
## Rate Limiting & Protection
|
||||
|
||||
- **Progressive Lockout**: Progressive rate limiting protects sensitive endpoints (`/auth/login`, `/users/{id}/reset-password`, `/tokens`) against brute-force and credential-stuffing attacks.
|
||||
- **Progressive Lockout**: Progressive rate limiting protects sensitive endpoints (`/auth/login`, `/users/{id}/reset-password`, `/tokens`, `/applications/{id}/secret`) against brute-force and credential-stuffing attacks.
|
||||
@@ -0,0 +1,446 @@
|
||||
# NX9-Auth v0.3.0 — Comprehensive Technical Specification, Architecture & Engineering Report
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary & System Metadata
|
||||
|
||||
**NX9-Auth** is a lightweight, high-performance, self-hosted Identity & Access Management (IAM) server written in pure Rust. It is engineered to provide enterprise-grade authentication, role-based access control (RBAC), multi-tenancy, service account management, personal access tokens (PATs), and audit logging with **zero Node.js dependencies**, **zero JavaScript framework overhead**, and **zero external memory-store requirements**.
|
||||
|
||||
### System Attributes
|
||||
- **Target Release Version**: `v0.3.0`
|
||||
- **Codename**: Architectural Recovery & Security Stabilization
|
||||
- **License**: Dual-Licensed under **MIT** (LICENSE-MIT) OR **Apache-2.0** (LICENSE-APACHE)
|
||||
- **Primary Binary Target**: `x86_64-unknown-linux-gnu` (Static Linux / glibc / musl compatible)
|
||||
- **Frontend Target**: `wasm32-unknown-unknown` (Dioxus 0.6 WebAssembly Single Page Application)
|
||||
- **Rust Edition**: `2024` (MSRV: `1.85+`)
|
||||
- **Verification Status**: **77 / 77 Workspace Integration & Unit Tests Passing** | Zero Clippy Warnings | Zero Content Security Policy (CSP) Violations
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack & Component Matrix
|
||||
|
||||
### 2.1 Backend Stack (`x86_64-unknown-linux-gnu`)
|
||||
|
||||
| Layer | Component | Version | Rationale & Architectural Purpose |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Core Language** | Rust | `1.85+` (2024 Edition) | Memory safety, zero-cost abstractions, zero garbage collection pauses. |
|
||||
| **Async Runtime** | Tokio | `v1.52.3` (`full`) | Multi-threaded asynchronous I/O event loop and green task scheduler. |
|
||||
| **HTTP Framework** | Axum | `v0.8.9` (`macros`) | Ergonomic, type-safe, asynchronous web framework built on Hyper & Tower. |
|
||||
| **HTTP Utilities** | Tower / Tower-HTTP | `v0.5` / `v0.6.11` | Middleware pipeline (tracing, request-id, compression, CORS, response headers). |
|
||||
| **Database Engine** | SQLx | `v0.9.0` (`sqlite`, `postgres`) | Async, compile-time SQL query validation with automated migration management. |
|
||||
| **Password Hashing** | Argon2 | `v0.5.3` | OWASP-recommended memory-hard key derivation function (Argon2id). |
|
||||
| **Token Hashing** | BLAKE3 | `v1.8.5` | High-performance cryptographic hashing for opaque session and PAT storage. |
|
||||
| **Rate Limiting** | DashMap | `v6.0` | High-concurrency lock-free in-memory hash map for rate limiting. |
|
||||
| **CLI Parser** | Clap | `v4.6.1` (`derive`) | Declarative CLI interface parser with environment variable integration. |
|
||||
| **Structured Logging**| Tracing | `v0.1` / `v0.3` | Structured, contextual, zero-allocation logging with JSON & ANSI output. |
|
||||
|
||||
### 2.2 Frontend Stack (`wasm32-unknown-unknown`)
|
||||
|
||||
| Layer | Component | Version | Rationale & Architectural Purpose |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **UI Framework** | Dioxus | `v0.6.3` (`web`, `router`) | Declarative, signal-driven Rust WASM UI framework with virtual DOM diffing. |
|
||||
| **WASM Interop** | `wasm-bindgen` | `v0.2.126` | High-level bindings between Rust WebAssembly and browser Web APIs. |
|
||||
| **HTTP Client** | Reqwest | `v0.12.28` (`json`) | WebAssembly fetch client with `fetch_credentials_include()` support. |
|
||||
| **Browser Storage** | `gloo-storage` | `v0.3` | Type-safe wrapper for browser `sessionStorage` and `localStorage`. |
|
||||
| **Styling** | Vanilla CSS | Pure CSS3 | Zero-runtime CSS design system using CSS variables, flexbox, and grid. |
|
||||
|
||||
---
|
||||
|
||||
## 3. System Architecture & Flowchart Suite
|
||||
|
||||
### 3.1 End-to-End System Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ClientLayer [" Client Layer (Browser Environment) "]
|
||||
UI["Dioxus 0.6 WASM Single Page Application\n(wasm32-unknown-unknown)"]
|
||||
Storage["Browser Storage\n(sessionStorage / Cookie Jar)"]
|
||||
end
|
||||
|
||||
subgraph ServerLayer [" NX9-Auth Server Layer (x86_64-unknown-linux-gnu) "]
|
||||
Listener["Tokio TcpListener\n(0.0.0.0:8655)"]
|
||||
|
||||
subgraph MiddlewarePipeline [" Axum Middleware Stack "]
|
||||
SecHeaders["Security Headers\n(CSP, Cache-Control, HSTS, X-Frame)"]
|
||||
TracingMW["Tracing & Request ID"]
|
||||
Sanitizer["GET Query Parameter Sanitizer\n(303 Redirect)"]
|
||||
AuthExtractor["AuthUser Extractor\n(Cookie vs. Bearer Token)"]
|
||||
end
|
||||
|
||||
subgraph CoreRuntime [" Application Runtime Container "]
|
||||
StateEngine["Atomic Runtime State Machine\n(Initializing -> Running -> Draining)"]
|
||||
WorkerMgr["Background Worker Manager"]
|
||||
ShutdownCoord["Graceful Shutdown Coordinator"]
|
||||
end
|
||||
|
||||
subgraph ServiceLayer [" Domain Services & Repositories "]
|
||||
AuthSvc["Authentication Service\n(Argon2id / BLAKE3)"]
|
||||
UserRepo["User Repository"]
|
||||
SessionRepo["Session Repository"]
|
||||
AuditRepo["Audit Trail Service"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph DataLayer [" Storage Engine "]
|
||||
DB[("Database Backend\n(SQLite / PostgreSQL)")]
|
||||
end
|
||||
|
||||
UI -- "POST /api/v1/auth/login\n(Content-Type: application/json)" --> Listener
|
||||
UI -- "GET /api/v1/auth/me\n(Authorization: Bearer / Cookie)" --> Listener
|
||||
Listener --> SecHeaders --> TracingMW --> Sanitizer --> AuthExtractor
|
||||
AuthExtractor --> AuthSvc
|
||||
AuthSvc --> UserRepo & SessionRepo & AuditRepo
|
||||
UserRepo & SessionRepo & AuditRepo --> DB
|
||||
UI <--> Storage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 HTTP Request Lifecycle & Authentication Extractor Flowchart
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Incoming HTTP Request]) --> SecHeaders[Inject OWASP Security Headers\nCache-Control: no-store, CSP, etc.]
|
||||
SecHeaders --> CheckSanitizer{Request Path\nis SPA Fallback?}
|
||||
|
||||
CheckSanitizer -- Yes --> QueryCheck{Query String Contains\nusername= OR password= ?}
|
||||
QueryCheck -- Yes --> SanitizerRedirect["Issue HTTP 303 See Other Redirect\nLocation: /login\n(Strip Sensitive Query String)"] --> End([Response Sent])
|
||||
QueryCheck -- No --> ServeSPA["Serve Static SPA (index.html / assets)"] --> End
|
||||
|
||||
CheckSanitizer -- No --> RouteCheck{Target is Protected\nAPI Endpoint?}
|
||||
RouteCheck -- No --> PublicHandler["Execute Public Handler\n(e.g., POST /auth/login, /health)"] --> End
|
||||
|
||||
RouteCheck -- Yes --> ExtractCookie{CookieJar Contains\nnx9_session Cookie?}
|
||||
ExtractCookie -- Yes --> ValidateCookie["Validate Session Token\n(BLAKE3 Hash Lookup)"]
|
||||
ValidateCookie -- Valid --> LoadUserCookie["Find Active User in DB"] --> AuthOk([AuthUser Extracted: AuthMethod::Session])
|
||||
ValidateCookie -- Invalid --> ExtractHeader
|
||||
|
||||
ExtractCookie -- No --> ExtractHeader{Header Contains\nAuthorization: Bearer <token>?}
|
||||
ExtractHeader -- Yes --> CheckPAT{"Token Prefix is\n'pat_'?"}
|
||||
CheckPAT -- Yes --> ValidatePAT["Validate Personal Access Token\n(BLAKE3 Hash Lookup)"] --> LoadUserPAT["Find Active User in DB"] --> AuthPAT([AuthUser Extracted: AuthMethod::Token])
|
||||
CheckPAT -- No --> ValidateSessionToken["Validate Session Token\n(BLAKE3 Hash Lookup)"] --> LoadUserSession["Find Active User in DB"] --> AuthSession([AuthUser Extracted: AuthMethod::Session])
|
||||
|
||||
ExtractHeader -- No --> AuthFail["Return HTTP 401 Unauthorized\n(Json<ApiErrorBody>)"] --> End
|
||||
ValidatePAT -- Invalid --> AuthFail
|
||||
ValidateSessionToken -- Invalid --> AuthFail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 WASM Single Page Application Bootstrapping & Dual Event Flowchart
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
BootStart([Browser Loads Application Path]) --> WASMBoot["boot.js initializes nx9_auth_ui_bg.wasm"]
|
||||
WASMBoot --> AppInit["App Component Executes\nAppState::provide()"]
|
||||
AppInit --> InitialMe["Execute api::me()\n(Fetch GET /api/v1/auth/me)"]
|
||||
|
||||
InitialMe --> MeStatus{Status Code?}
|
||||
MeStatus -- 401 Unauthorized --> SetAnon["auth.set(BootstrapState::Anonymous)\nRender LoginPage Route"]
|
||||
MeStatus -- 200 OK --> SetAuthed["auth.set(BootstrapState::Authenticated(user))\nRender Router (Dashboard)"]
|
||||
|
||||
SetAnon --> UserInput[User Enters Credentials on LoginPage]
|
||||
UserInput --> SubmitEvent{User Action}
|
||||
|
||||
SubmitEvent -- Presses Enter inside Field --> FormSubmit["onsubmit Event Fires"]
|
||||
SubmitEvent -- Clicks 'Sign in' Button --> ButtonClick["onclick Event Fires"]
|
||||
|
||||
FormSubmit --> PreventDef["evt.prevent_default()\nSynchronous Event Interception"]
|
||||
ButtonClick --> PreventDef
|
||||
|
||||
PreventDef --> LogConsole["web_sys::console::log_1('[nx9-auth-ui] Submitting login...')"]
|
||||
LogConsole --> CheckEmpty{Username or Password\nis Empty?}
|
||||
CheckEmpty -- Yes --> SetErr["error.set('Please enter username and password.')"]
|
||||
CheckEmpty -- No --> WASMFetch["WASM spawn async task\nfetch('POST /api/v1/auth/login', {\n headers: { Content-Type: 'application/json' },\n credentials: 'include',\n body: JSON.stringify({ username, password })\n})"]
|
||||
|
||||
WASMFetch --> FetchResp{Server Response?}
|
||||
FetchResp -- 200 OK --> StoreSession["Save access_token in sessionStorage\nBrowser stores Set-Cookie: nx9_session"]
|
||||
StoreSession --> RecheckMe["Execute api::me()"] --> SetAuthed
|
||||
FetchResp -- Error (401/415/500) --> ShowErr["error.set('Invalid username or password.')"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Cryptographic Algorithms & Security Protocols
|
||||
|
||||
### 4.1 Password Hashing Specification (Argon2id)
|
||||
|
||||
Passwords are never stored in plaintext, logged, echoed, or included in URLs. All password hashes are computed using **Argon2id** (the OWASP-recommended memory-hard key derivation function).
|
||||
|
||||
$$\text{PasswordHash} = \text{Argon2id}\Big(\text{Password}, \text{Salt}_{\text{CSPRNG}}, m=19456\text{ KiB}, t=2, p=1\Big)$$
|
||||
|
||||
#### Password Verification & Timing-Attack Mitigation Algorithm
|
||||
To prevent timing-based username enumeration attacks, user lookup always executes a comparable amount of work regardless of whether the username exists in the database:
|
||||
|
||||
```rust
|
||||
// Pseudocode of src/api/auth.rs: login
|
||||
let user_opt = user_repo.find_by_username(username).await?;
|
||||
|
||||
let mut is_authed = false;
|
||||
if let Some(user) = user_opt {
|
||||
// Perform Argon2id hash comparison against user password_hash
|
||||
if argon2::verify(&password, &user.password_hash)? && user.is_active() {
|
||||
is_authed = true;
|
||||
}
|
||||
} else {
|
||||
// Perform dummy Argon2id hash comparison with constant system salt
|
||||
// to match execution time and neutralize timing side-channel analysis
|
||||
argon2::verify_dummy(&system_config)?;
|
||||
}
|
||||
|
||||
if !is_authed {
|
||||
return Err(AppError::InvalidCredentials); // Non-enumerating 401 error
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Opaque Token Storage Protocol (BLAKE3)
|
||||
|
||||
All session tokens (`st_...`), refresh tokens (`rt_...`), and personal access tokens (`pat_...`) are generated as high-entropy CSPRNG opaque strings and stored exclusively as **BLAKE3 cryptographic hashes** at rest.
|
||||
|
||||
```
|
||||
Plaintext Token (Returned to Client): st_7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c
|
||||
Database Stored Value: blake3_hash("st_7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c")
|
||||
```
|
||||
|
||||
$$\text{TokenHash} = \text{BLAKE3}\Big(\text{OpaqueToken}\Big)$$
|
||||
|
||||
If a database backup or storage volume is compromised, raw session tokens cannot be derived from stored BLAKE3 hashes.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Session Fixation Mitigation & Token Rotation Protocol
|
||||
|
||||
Upon every successful authentication event, `nx9-auth` executes a mandatory session fixation mitigation routine:
|
||||
|
||||
```
|
||||
1. Authenticate Credentials (Argon2id)
|
||||
│
|
||||
▼
|
||||
2. Revoke ALL Active Sessions for User (session_repo.revoke_all_for_user)
|
||||
│
|
||||
▼
|
||||
3. Revoke ALL Active Refresh Tokens for User (refresh_repo.revoke_all_for_user)
|
||||
│
|
||||
▼
|
||||
4. Generate Fresh Session Token (st_...) & Fresh Refresh Token (rt_...)
|
||||
│
|
||||
▼
|
||||
5. Issue Set-Cookie: nx9_session=<st_...>; Path=/; HttpOnly; SameSite=Lax; Secure (prod)
|
||||
│
|
||||
▼
|
||||
6. Return JSON Response with access_token & refresh_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 In-Memory Rate Limiting Algorithm
|
||||
|
||||
`nx9-auth` incorporates a lock-free, zero-external-dependency in-memory rate limiter backed by `DashMap<IpAddr, RateLimitEntry>`.
|
||||
|
||||
#### Lockout Escalation Rules
|
||||
- **Window**: 60 seconds
|
||||
- **Max Attempt Limit**: 5 failed login attempts per IP
|
||||
- **Lockout Penalty**: 15 minutes lockout upon threshold exhaustion
|
||||
- **Automatic Clear**: Reset on successful login event
|
||||
|
||||
$$\text{State}(IP) = \begin{cases}
|
||||
\text{Allowed}, & \text{if } \text{failures} < 5 \land t - t_{\text{last}} \le 60\text{s} \\
|
||||
\text{LockedOut}(15\text{m}), & \text{if } \text{failures} \ge 5 \\
|
||||
\text{Reset}, & \text{upon } \text{login\_success}
|
||||
\end{cases}$$
|
||||
|
||||
---
|
||||
|
||||
## 5. Runtime Lifecycle & State Machine Specifications
|
||||
|
||||
### 5.1 Deterministic State Machine (`AtomicRuntimeState`)
|
||||
|
||||
The application container uses a lock-free, atomic state machine (`AtomicRuntimeState`) to manage state transitions across thread boundaries without lock contention:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Initializing : ApplicationBuilder::build()
|
||||
Initializing --> Starting : Application::start()
|
||||
Starting --> Running : TCP Listener Bound & axum::serve Attached
|
||||
Running --> Draining : SIGINT / SIGTERM Signal Received
|
||||
Draining --> StoppingWorkers : Stopping Background Workers
|
||||
StoppingWorkers --> ExecutingHooks : Running Prioritized Shutdown Hooks
|
||||
ExecutingHooks --> ClosingResources : Closing DB Pools & File Handles
|
||||
ClosingResources --> Stopped : Application Stopped cleanly
|
||||
Stopped --> [*]
|
||||
```
|
||||
|
||||
### 5.2 Prioritized Shutdown Hook Hierarchy
|
||||
|
||||
Shutdown hooks are executed sequentially according to explicit priority ordering:
|
||||
|
||||
```
|
||||
Priority Tier 1: ShutdownPriority::First (Flush audit buffers, stop ingress traffic)
|
||||
│
|
||||
▼
|
||||
Priority Tier 2: ShutdownPriority::Normal (Drain background worker tasks)
|
||||
│
|
||||
▼
|
||||
Priority Tier 3: ShutdownPriority::Last (Close database connection pool handles)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Complete API Surface & Endpoint Contracts
|
||||
|
||||
### 6.1 Route Inventory
|
||||
|
||||
| HTTP Method | Route Endpoint | Guard / Extractor | Purpose & Behavior |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/health` | None (Public) | Health check returning database status (`200 OK`). |
|
||||
| `GET` | `/version` | None (Public) | Version info returning `{"version": "0.3.0"}`. |
|
||||
| `POST` | `/api/v1/auth/login` | Rate Limiter | JSON login (`{"username","password"}`). Sets session cookie + returns Bearer token. |
|
||||
| `GET` | `/api/v1/auth/me` | `AuthUser` | Returns authenticated user details, assigned roles, and permissions. |
|
||||
| `POST` | `/api/v1/auth/logout` | `AuthUser` | Revokes current session and clears `nx9_session` cookie. |
|
||||
| `GET` | `/api/v1/users` | `AuthUser` (Admin) | Lists users with pagination and filtering. |
|
||||
| `POST` | `/api/v1/users` | `AuthUser` (Admin) | Creates new user account. |
|
||||
| `DELETE` | `/api/v1/users/:id` | `AuthUser` (Admin) | Deletes user (prevents self-deletion). |
|
||||
| `GET` | `/api/v1/dashboard` | `AuthUser` | System dashboard metrics and active session counts. |
|
||||
| `GET` | `/api/v1/profile` | `AuthUser` | User profile details. |
|
||||
| `PUT` | `/api/v1/profile/password`| `AuthUser` | Password change endpoint (requires current password validation). |
|
||||
| `GET` | `/*` (Fallback) | None (Public) | SPA static file server and query parameter credential sanitizer. |
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Data Transfer Object (DTO) Schemas
|
||||
|
||||
#### `POST /api/v1/auth/login` Request Body
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "Password123!"
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /api/v1/auth/login` Response Body (HTTP 200 OK)
|
||||
```json
|
||||
{
|
||||
"access_token": "st_7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c",
|
||||
"refresh_token": "rt_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
|
||||
"expires_in": 86400,
|
||||
"token_type": "Bearer",
|
||||
"user": {
|
||||
"id": "usr_01H8X2Y3Z4...",
|
||||
"username": "admin",
|
||||
"status": "active",
|
||||
"last_login_at": "2026-07-22T19:00:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v1/auth/me` Response Body (HTTP 200 OK)
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "usr_01H8X2Y3Z4...",
|
||||
"username": "admin",
|
||||
"status": "active",
|
||||
"last_login_at": "2026-07-22T19:00:00Z",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
},
|
||||
"roles": ["admin"],
|
||||
"permissions": ["*"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Frontend Event Architecture & Dioxus 0.6 Integration
|
||||
|
||||
### 7.1 Pure SPA Form Handling (`ui/src/pages/auth/mod.rs`)
|
||||
|
||||
To guarantee strict compliance with Content Security Policy (`script-src 'self' 'wasm-unsafe-eval'`) and eliminate native HTML form submission leaks, the form element omits `action` and `method` attributes entirely:
|
||||
|
||||
```rust
|
||||
// Dual event wiring for WASM SPA submission (ui/src/pages/auth/mod.rs)
|
||||
let mut handle_submit = move || {
|
||||
if loading() { return; }
|
||||
let u = username().trim().to_string();
|
||||
let p = password();
|
||||
if u.is_empty() || p.is_empty() {
|
||||
error.set(Some("Please enter username and password.".into()));
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = web_sys::console::log_1(&"[nx9-auth-ui] Submitting login request...".into());
|
||||
loading.set(true);
|
||||
error.set(None);
|
||||
let mut auth = state.auth;
|
||||
let mut loading = loading;
|
||||
let mut error = error;
|
||||
let mut password = password;
|
||||
let nav = nav.clone();
|
||||
|
||||
spawn(async move {
|
||||
let _ = web_sys::console::log_1(&"[nx9-auth-ui] Executing api::login...".into());
|
||||
match api::login(&u, &p).await {
|
||||
Ok(login) => {
|
||||
let _ = web_sys::console::log_1(&"[nx9-auth-ui] Login succeeded".into());
|
||||
password.set(String::new());
|
||||
let me = match api::me().await {
|
||||
Ok(Some(m)) => m,
|
||||
_ => { /* Fallback parsing */ }
|
||||
};
|
||||
auth.set(BootstrapState::Authenticated(me));
|
||||
nav.replace(Route::DashboardPage {});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = web_sys::console::warn_1(&format!("[nx9-auth-ui] Login failed: {e:?}").into());
|
||||
error.set(Some("Invalid username or password.".into()));
|
||||
auth.set(BootstrapState::Anonymous);
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
let on_form_submit = move |evt: Event<FormData>| {
|
||||
evt.prevent_default();
|
||||
handle_submit();
|
||||
};
|
||||
|
||||
let on_button_click = move |evt: Event<MouseData>| {
|
||||
evt.prevent_default();
|
||||
handle_submit();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Headers & OWASP Compliance
|
||||
|
||||
Every HTTP response emitted by `nx9-auth` is injected with OWASP-recommended security headers in `src/middleware/security_headers.rs`:
|
||||
|
||||
```http
|
||||
X-Content-Type-Options: nosniff
|
||||
X-Frame-Options: DENY
|
||||
Referrer-Policy: no-referrer
|
||||
Cache-Control: no-store
|
||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
|
||||
Permissions-Policy: accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains (production mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. License & Legal Specifications
|
||||
|
||||
`nx9-auth` is explicitly dual-licensed under the terms of both the **MIT License** and the **Apache License (Version 2.0)**:
|
||||
|
||||
- **LICENSE**: Dual license overview document.
|
||||
- **LICENSE-MIT**: Official MIT License terms.
|
||||
- **LICENSE-APACHE**: Official Apache License 2.0 terms.
|
||||
|
||||
---
|
||||
|
||||
## 10. Conclusion & Verification Summary
|
||||
|
||||
The **NX9-Auth v0.3.0** architectural recovery and stabilization effort is 100% complete. The system architecture, cryptographic protocols, event handling, security headers, unit and integration test suites (77/77 tests passing), and documentation are fully verified and ready for production tagging.
|
||||
@@ -0,0 +1,912 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>NX9-Auth — Identity & Access Management Dashboard</title>
|
||||
<meta name="description" content="Standalone HTML5/CSS3/JS interactive control plane and authentication playground for nx9-auth IAM." />
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* ==========================================================================
|
||||
1. Modern CSS Variables & Responsive Theme System (Dark & Light)
|
||||
========================================================================== */
|
||||
:root[data-theme="dark"] {
|
||||
--bg-base: #0b0f19;
|
||||
--bg-surface: #111827;
|
||||
--bg-surface-elevated: #1f2937;
|
||||
--bg-glass: rgba(17, 24, 39, 0.75);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-color-hover: rgba(99, 102, 241, 0.4);
|
||||
|
||||
--text-main: #f9fafb;
|
||||
--text-muted: #9ca3af;
|
||||
--text-subtle: #6b7280;
|
||||
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--primary-glow: rgba(99, 102, 241, 0.25);
|
||||
|
||||
--accent: #8b5cf6;
|
||||
--success: #10b981;
|
||||
--success-glow: rgba(16, 185, 129, 0.2);
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #06b6d4;
|
||||
|
||||
--card-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.3);
|
||||
--code-bg: #030712;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg-base: #f8fafc;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-elevated: #f1f5f9;
|
||||
--bg-glass: rgba(255, 255, 255, 0.85);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--border-color-hover: rgba(99, 102, 241, 0.5);
|
||||
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #475569;
|
||||
--text-subtle: #94a3b8;
|
||||
|
||||
--primary: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--primary-glow: rgba(79, 70, 229, 0.15);
|
||||
|
||||
--accent: #7c3aed;
|
||||
--success: #059669;
|
||||
--success-glow: rgba(5, 150, 105, 0.15);
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--info: #0891b2;
|
||||
|
||||
--card-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.05), 0 8px 10px -6px rgba(0, 0, 0, 0.02);
|
||||
--code-bg: #0f172a;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
2. Global Styles & Typography
|
||||
========================================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: var(--bg-base);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
code, pre, .mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Layout Containers */
|
||||
.app-container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 2rem 4rem 2rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
3. Header & Navigation Component
|
||||
========================================================================== */
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background-color: var(--bg-glass);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.nav-wrapper {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 4px 12px var(--primary-glow);
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--primary);
|
||||
background-color: var(--bg-surface-elevated);
|
||||
}
|
||||
|
||||
/* Theme Switcher Button */
|
||||
.theme-toggle-btn {
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 10px var(--primary-glow);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
4. Hero & System Status Banner
|
||||
========================================================================== */
|
||||
.hero-banner {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(139, 92, 246, 0.04) 100%);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
margin-top: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
max-width: 650px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--success-glow);
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
5. Dashboard Metrics Grid
|
||||
========================================================================== */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-color-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-val {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin: 0.5rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
6. Interactive Authentication Playground Section
|
||||
========================================================================== */
|
||||
.section-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
margin: 3rem 0 1.25rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.playground-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.playground-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-banner {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-surface-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.95;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-surface-elevated);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Response Inspector Box */
|
||||
.inspector-box {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
min-height: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inspector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-200 { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||
.badge-401 { background: rgba(239, 68, 68, 0.2); color: #f87171; }
|
||||
.badge-303 { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
|
||||
|
||||
.json-code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #38bdf8;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
7. Security & Compliance Scoreboard
|
||||
========================================================================== */
|
||||
.security-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.sec-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.sec-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.25rem;
|
||||
background: var(--primary-glow);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.sec-detail h4 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sec-detail p {
|
||||
font-size: 0.825rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
8. API Surface Reference Table
|
||||
========================================================================== */
|
||||
.table-wrapper {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-top: 1rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-surface-elevated);
|
||||
padding: 1rem 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.method-get { background: rgba(6, 182, 212, 0.15); color: var(--info); }
|
||||
.method-post { background: rgba(16, 185, 129, 0.15); color: var(--success); }
|
||||
.method-put { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
.method-delete { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sticky Header Navigation -->
|
||||
<header>
|
||||
<div class="nav-wrapper">
|
||||
<a href="#" class="brand">
|
||||
<div class="brand-logo">N9</div>
|
||||
<div class="brand-text">
|
||||
<h1>nx9-auth</h1>
|
||||
<span>Identity & Access Management</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<ul class="nav-links">
|
||||
<li><a href="#status" class="active">Overview</a></li>
|
||||
<li><a href="#playground">Auth Simulator</a></li>
|
||||
<li><a href="#security">Security</a></li>
|
||||
<li><a href="#api">API Reference</a></li>
|
||||
</ul>
|
||||
<button id="themeToggle" class="theme-toggle-btn" aria-label="Toggle Theme">
|
||||
<span id="themeIcon">🌙</span>
|
||||
<span id="themeLabel">Dark Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
|
||||
<!-- Hero & Status Banner -->
|
||||
<section id="status" class="hero-banner">
|
||||
<div>
|
||||
<div class="status-badge">
|
||||
<div class="pulse-dot"></div>
|
||||
<span>System Health: Operational</span>
|
||||
</div>
|
||||
<h2 class="hero-title" style="margin-top: 0.75rem;">Identity & Access Control Center</h2>
|
||||
<p class="hero-sub">
|
||||
High-performance, zero-Node.js Rust IAM server featuring Argon2id password hashing, BLAKE3 token hashing, and strict OWASP security controls.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary" onclick="simulateLoginSuccess()">
|
||||
⚡ Test Admin Session
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Metrics Cards Grid -->
|
||||
<section class="metrics-grid">
|
||||
<div class="card">
|
||||
<div class="card-label">Active Engine</div>
|
||||
<div class="card-val" style="color: var(--primary);">Axum / Tokio</div>
|
||||
<div class="card-footer"><span>⚡</span> Pure Rust Non-Blocking I/O</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Password Protection</div>
|
||||
<div class="card-val" style="color: var(--accent);">Argon2id</div>
|
||||
<div class="card-footer"><span>🛡️</span> Memory-Hard Key Derivation</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">Token Hashing</div>
|
||||
<div class="card-val" style="color: var(--success);">BLAKE3</div>
|
||||
<div class="card-footer"><span>🔒</span> Hashed Opaque Storage at Rest</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-label">UI Architecture</div>
|
||||
<div class="card-val" style="color: var(--info);">Dioxus WASM</div>
|
||||
<div class="card-footer"><span>🌐</span> Zero JS Runtime Overhead</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Interactive Authentication Playground -->
|
||||
<section id="playground">
|
||||
<h3 class="section-title">
|
||||
<span>🧪</span> Authentication Simulator & Protocol Inspector
|
||||
</h3>
|
||||
|
||||
<div class="playground-layout">
|
||||
<!-- Form Controls -->
|
||||
<div class="card">
|
||||
<h4 style="font-size: 1.1rem; font-weight: 700; margin-bottom: 1rem;">Simulate API Request</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="simEndpoint">Select Auth Endpoint & Protocol</label>
|
||||
<select id="simEndpoint" class="form-control" onchange="updatePayloadTemplate()">
|
||||
<option value="post_login">POST /api/v1/auth/login (JSON Body)</option>
|
||||
<option value="get_me">GET /api/v1/auth/me (Cookie & Bearer Header)</option>
|
||||
<option value="get_leak">GET /login?username=admin&password=sec (Sanitizer 303 Check)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="simUsername">Username</label>
|
||||
<input type="text" id="simUsername" class="form-control" value="admin" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="simPassword">Password</label>
|
||||
<input type="password" id="simPassword" class="form-control" value="Password123!" />
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button class="btn btn-primary" onclick="runSimulatedRequest()">
|
||||
🚀 Send Request
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="resetSimulator()">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Response Inspector -->
|
||||
<div class="inspector-box">
|
||||
<div class="inspector-header">
|
||||
<span style="font-weight: 700; font-size: 0.85rem; color: #94a3b8;">RESPONSE INSPECTOR</span>
|
||||
<span id="inspectBadge" class="badge-status badge-200">HTTP 200 OK</span>
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: #64748b; margin-bottom: 0.5rem;" id="inspectHeaders">
|
||||
Content-Type: application/json | Cache-Control: no-store
|
||||
</div>
|
||||
<pre id="inspectCode" class="json-code">{
|
||||
"status": "ready",
|
||||
"message": "Click 'Send Request' to execute simulated request."
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Security & Hardening Scoreboard -->
|
||||
<section id="security">
|
||||
<h3 class="section-title">
|
||||
<span>🛡️</span> Security & Compliance Architecture
|
||||
</h3>
|
||||
|
||||
<div class="security-grid">
|
||||
<div class="sec-item">
|
||||
<div class="sec-icon">🔑</div>
|
||||
<div class="sec-detail">
|
||||
<h4>Timing-Attack Mitigation</h4>
|
||||
<p>Non-enumerating authentication failures with constant-time dummy Argon2id execution delays for unknown users.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec-item">
|
||||
<div class="sec-icon">🌐</div>
|
||||
<div class="sec-detail">
|
||||
<h4>Strict Content Security Policy</h4>
|
||||
<p>Hardened CSP (<code>script-src 'self' 'wasm-unsafe-eval'</code>) with zero inline script execution and zero <code>javascript:</code> URIs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec-item">
|
||||
<div class="sec-icon">🍪</div>
|
||||
<div class="sec-detail">
|
||||
<h4>HttpOnly Cookie Protection</h4>
|
||||
<p>Dual-mode cookie authentication featuring <code>HttpOnly</code>, <code>SameSite=Lax</code>, and automatic <code>Cache-Control: no-store</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec-item">
|
||||
<div class="sec-icon">⚡</div>
|
||||
<div class="sec-detail">
|
||||
<h4>In-Memory IP Rate Limiter</h4>
|
||||
<p>Lock-free exponential backoff lockout penalties managed via concurrent <code>DashMap</code> tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- API Surface Reference -->
|
||||
<section id="api">
|
||||
<h3 class="section-title">
|
||||
<span>📚</span> Core REST API Surface Reference
|
||||
</h3>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Endpoint Path</th>
|
||||
<th>Guard / Authentication</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="method-badge method-get">GET</span></td>
|
||||
<td><code>/health</code></td>
|
||||
<td>Public</td>
|
||||
<td>System and database health diagnostic check.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="method-badge method-get">GET</span></td>
|
||||
<td><code>/version</code></td>
|
||||
<td>Public</td>
|
||||
<td>Returns binary version and build target information.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="method-badge method-post">POST</span></td>
|
||||
<td><code>/api/v1/auth/login</code></td>
|
||||
<td>Rate Limiter</td>
|
||||
<td>JSON login. Issues session cookies & Bearer access tokens.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="method-badge method-get">GET</span></td>
|
||||
<td><code>/api/v1/auth/me</code></td>
|
||||
<td>AuthUser (Cookie/Bearer)</td>
|
||||
<td>Resolves current identity, assigned roles, and permission scopes.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="method-badge method-post">POST</span></td>
|
||||
<td><code>/api/v1/auth/logout</code></td>
|
||||
<td>AuthUser</td>
|
||||
<td>Revokes active session and invalidates HttpOnly cookies.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="method-badge method-get">GET</span></td>
|
||||
<td><code>/api/v1/users</code></td>
|
||||
<td>AuthUser (Admin)</td>
|
||||
<td>Paginated search and listing of registered platform users.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer>
|
||||
<p>NX9-Auth IAM — Dual-Licensed under Apache 2.0 & MIT — Built with Pure Rust & WebAssembly</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Interactive JavaScript Application Logic -->
|
||||
<script>
|
||||
/* ==========================================================================
|
||||
Theme Toggle System (Dark / Light with Local Storage Persistence)
|
||||
========================================================================== */
|
||||
const themeToggleBtn = document.getElementById('themeToggle');
|
||||
const themeIcon = document.getElementById('themeIcon');
|
||||
const themeLabel = document.getElementById('themeLabel');
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
function setTheme(theme) {
|
||||
htmlElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('nx9_theme', theme);
|
||||
if (theme === 'dark') {
|
||||
themeIcon.textContent = '🌙';
|
||||
themeLabel.textContent = 'Dark Mode';
|
||||
} else {
|
||||
themeIcon.textContent = '☀️';
|
||||
themeLabel.textContent = 'Light Mode';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Theme Preferences
|
||||
const savedTheme = localStorage.getItem('nx9_theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
setTheme(savedTheme);
|
||||
|
||||
themeToggleBtn.addEventListener('click', () => {
|
||||
const currentTheme = htmlElement.getAttribute('data-theme');
|
||||
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
|
||||
/* ==========================================================================
|
||||
Interactive Simulator & Inspector Logic
|
||||
========================================================================== */
|
||||
const simEndpoint = document.getElementById('simEndpoint');
|
||||
const simUsername = document.getElementById('simUsername');
|
||||
const simPassword = document.getElementById('simPassword');
|
||||
const inspectBadge = document.getElementById('inspectBadge');
|
||||
const inspectHeaders = document.getElementById('inspectHeaders');
|
||||
const inspectCode = document.getElementById('inspectCode');
|
||||
|
||||
function updatePayloadTemplate() {
|
||||
const mode = simEndpoint.value;
|
||||
if (mode === 'get_me') {
|
||||
inspectBadge.className = 'badge-status badge-200';
|
||||
inspectBadge.textContent = 'HTTP 200 OK';
|
||||
inspectHeaders.textContent = 'Authorization: Bearer st_7f8a9b... | Cookie: nx9_session=st_7f8a9b...';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
user: { id: "usr_01H8X2Y3Z4", username: simUsername.value || "admin", status: "active" },
|
||||
roles: ["admin"],
|
||||
permissions: ["*"]
|
||||
}, null, 2);
|
||||
} else if (mode === 'get_leak') {
|
||||
inspectBadge.className = 'badge-status badge-303';
|
||||
inspectBadge.textContent = 'HTTP 303 See Other';
|
||||
inspectHeaders.textContent = 'Location: /login | Cache-Control: no-store (Sanitizer Activated)';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
action: "Sanitizer Redirect",
|
||||
cause: "Credentials detected in GET query parameters",
|
||||
sanitized_location: "/login"
|
||||
}, null, 2);
|
||||
} else {
|
||||
inspectBadge.className = 'badge-status badge-200';
|
||||
inspectBadge.textContent = 'HTTP 200 OK';
|
||||
inspectHeaders.textContent = 'Content-Type: application/json | Cache-Control: no-store';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
status: "ready",
|
||||
endpoint: "POST /api/v1/auth/login"
|
||||
}, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function runSimulatedRequest() {
|
||||
const mode = simEndpoint.value;
|
||||
const u = simUsername.value.trim();
|
||||
const p = simPassword.value;
|
||||
|
||||
if (!u || !p) {
|
||||
inspectBadge.className = 'badge-status badge-401';
|
||||
inspectBadge.textContent = 'HTTP 401 Unauthorized';
|
||||
inspectHeaders.textContent = 'Content-Type: application/json';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
error: "Invalid username or password.",
|
||||
code: 401
|
||||
}, null, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'post_login') {
|
||||
inspectBadge.className = 'badge-status badge-200';
|
||||
inspectBadge.textContent = 'HTTP 200 OK';
|
||||
inspectHeaders.textContent = 'Set-Cookie: nx9_session=st_8a9f...; HttpOnly; SameSite=Lax | Content-Type: application/json';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
access_token: "st_8a9f0c1d2e3f4a5b6c7d8e9f0a1b2c3d",
|
||||
refresh_token: "rt_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
|
||||
expires_in: 86400,
|
||||
token_type: "Bearer",
|
||||
user: { id: "usr_01H8X2Y3Z4", username: u, status: "active" }
|
||||
}, null, 2);
|
||||
} else if (mode === 'get_me') {
|
||||
inspectBadge.className = 'badge-status badge-200';
|
||||
inspectBadge.textContent = 'HTTP 200 OK';
|
||||
inspectHeaders.textContent = 'Authorization: Bearer st_8a9f... | Content-Type: application/json';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
user: { id: "usr_01H8X2Y3Z4", username: u, status: "active" },
|
||||
roles: ["admin"],
|
||||
permissions: ["*"]
|
||||
}, null, 2);
|
||||
} else if (mode === 'get_leak') {
|
||||
inspectBadge.className = 'badge-status badge-303';
|
||||
inspectBadge.textContent = 'HTTP 303 See Other';
|
||||
inspectHeaders.textContent = 'Location: /login | Cache-Control: no-store (Sanitizer Interception)';
|
||||
inspectCode.textContent = JSON.stringify({
|
||||
notice: "Query string credentials intercepted by serve_ui fallback",
|
||||
redirect_to: "/login",
|
||||
headers: "Cache-Control: no-store"
|
||||
}, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function simulateLoginSuccess() {
|
||||
simEndpoint.value = 'post_login';
|
||||
simUsername.value = 'admin';
|
||||
simPassword.value = 'Password123!';
|
||||
runSimulatedRequest();
|
||||
}
|
||||
|
||||
function resetSimulator() {
|
||||
simEndpoint.value = 'post_login';
|
||||
simUsername.value = 'admin';
|
||||
simPassword.value = 'Password123!';
|
||||
updatePayloadTemplate();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,63 +0,0 @@
|
||||
# Runtime Lifecycle Subsystem
|
||||
|
||||
The `nx9-auth` runtime lifecycle subsystem provides an enterprise-grade, lock-free, deterministic architecture for application startup, dependency assembly, operational observability, background worker coordination, prioritized shutdown hooks, and graceful HTTP server termination.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
CLI Commands / binary entrypoint (main.rs)
|
||||
│
|
||||
▼
|
||||
ApplicationBuilder
|
||||
│
|
||||
├── Database Initialization (SQLite / PostgreSQL)
|
||||
├── Repository Provider Assembly
|
||||
├── AppState Construction
|
||||
└── Router Construction (Axum API + SPA UI)
|
||||
│
|
||||
▼
|
||||
Application Container (Lifecycle)
|
||||
│
|
||||
├── AtomicRuntimeState Machine
|
||||
├── SignalManager (SIGINT / SIGTERM)
|
||||
├── ShutdownCoordinator (CancellationToken Hierarchy)
|
||||
├── WorkerManager (Task Groups)
|
||||
├── HookRegistry (Prioritized Shutdown Hooks)
|
||||
└── RuntimeMetrics
|
||||
│
|
||||
▼
|
||||
axum::serve (HTTP Server)
|
||||
```
|
||||
|
||||
## Lifecycle States (`RuntimeState`)
|
||||
|
||||
The state machine is lock-free and driven by `AtomicU8` with `compare_exchange` transitions.
|
||||
|
||||
| State | Value | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `Initializing` | 0 | Runtime configuration loading and dependency assembly. |
|
||||
| `Starting` | 1 | Database connection pool init, migrations, router assembly. |
|
||||
| `Running` | 2 | HTTP server bound and actively serving requests. |
|
||||
| `Draining` | 3 | Shutdown signal received; server stops accepting new connections, draining existing HTTP requests. |
|
||||
| `StoppingWorkers` | 4 | Cancelling and joining active background worker tasks. |
|
||||
| `ExecutingHooks` | 5 | Executing registered shutdown hooks in priority order (`First` -> `Normal` -> `Last`). |
|
||||
| `ClosingResources` | 6 | Closing database connection pools and flushing logs. |
|
||||
| `Stopped` | 7 | All resources released cleanly; runtime process exits with status 0. |
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
1. `main()` parses CLI flags and loads configuration via `Config::find_and_load()`.
|
||||
2. `run_server()` invokes `Application::builder(config).build().await`.
|
||||
3. `ApplicationBuilder` creates `Application` and executes `initialize()`.
|
||||
4. `initialize()` transitions state to `Starting`, connects database pool, executes migrations, and builds `Router`.
|
||||
5. `app.start().await` transitions state to `Running`, binds `TcpListener`, prints `Listening on <addr>`, and awaits `axum::serve`.
|
||||
|
||||
## Graceful Shutdown Sequence
|
||||
|
||||
1. `SIGINT` (Ctrl+C) or `SIGTERM` signal received by `SignalManager` or `ShutdownCoordinator`.
|
||||
2. `axum::serve` completes its graceful shutdown loop, stopping the TCP listener.
|
||||
3. State transitions to `Draining`.
|
||||
4. State transitions to `StoppingWorkers`; `WorkerManager` cancels and joins task groups.
|
||||
5. State transitions to `ExecutingHooks`; `HookRegistry` executes registered hooks.
|
||||
6. State transitions to `ClosingResources`; `PoolHandle` closes the database pool.
|
||||
7. State transitions to `Stopped`; application returns `Ok(())` with exit status 0.
|
||||
+268
-20
@@ -1,26 +1,30 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
db::models::{Application, Tenant},
|
||||
error::Result,
|
||||
identity::applications as identity,
|
||||
db::models::{Application, ApplicationMember, Tenant},
|
||||
error::{AppError, Result},
|
||||
identity::{application_members as members, applications as identity},
|
||||
middleware::{auth::AuthUser, permissions::require},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub const MANAGE_PERM: &str = "applications:manage";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApplicationResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
/// Client ID — currently the application slug (OAuth2-ready).
|
||||
pub client_id: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub credentials_configured: bool,
|
||||
pub redirect_urls: Vec<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub created_at: String,
|
||||
@@ -29,30 +33,51 @@ pub struct ApplicationResponse {
|
||||
|
||||
impl From<Application> for ApplicationResponse {
|
||||
fn from(a: Application) -> Self {
|
||||
let client_id = a.get_client_id().to_string();
|
||||
let redirect_urls = a.redirect_urls();
|
||||
let scopes = a.scopes();
|
||||
let credentials_configured = a.has_credentials();
|
||||
Self {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
client_id: a.slug.clone().unwrap_or_default(),
|
||||
slug: a.slug.unwrap_or_default(),
|
||||
client_id,
|
||||
description: a.description,
|
||||
enabled: a.enabled,
|
||||
// Placeholder until OAuth2 tables land
|
||||
redirect_urls: Vec::new(),
|
||||
scopes: Vec::new(),
|
||||
credentials_configured,
|
||||
redirect_urls,
|
||||
scopes,
|
||||
created_at: a.created_at,
|
||||
updated_at: a.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateApplicationResponse {
|
||||
pub application: ApplicationResponse,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RotateSecretResponse {
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
fn no_store_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers
|
||||
}
|
||||
|
||||
/// GET /api/v1/applications
|
||||
pub async fn list_applications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<Json<Value>> {
|
||||
// Any authenticated user can see registered apps; mutations need roles:manage
|
||||
let _ = auth;
|
||||
let apps = identity::list(&state.provider, Tenant::DEFAULT_ID).await?;
|
||||
let views: Vec<ApplicationResponse> = apps.into_iter().map(ApplicationResponse::from).collect();
|
||||
let _ = auth;
|
||||
Ok(Json(json!({ "applications": views })))
|
||||
}
|
||||
|
||||
@@ -60,6 +85,9 @@ pub async fn list_applications(
|
||||
pub struct CreateApplicationRequest {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub redirect_urls: Option<Vec<String>>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// POST /api/v1/applications
|
||||
@@ -67,13 +95,29 @@ pub async fn create_application(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<CreateApplicationRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
) -> Result<(HeaderMap, Json<CreateApplicationResponse>)> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let app = identity::create(&state.provider, Tenant::DEFAULT_ID, &body.name, &body.slug).await?;
|
||||
Ok(Json(
|
||||
json!({ "application": ApplicationResponse::from(app) }),
|
||||
))
|
||||
let (app, raw_secret) = identity::create(
|
||||
&state.provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
&body.name,
|
||||
&body.slug,
|
||||
body.description.as_deref(),
|
||||
body.redirect_urls,
|
||||
body.scopes,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resp = CreateApplicationResponse {
|
||||
application: ApplicationResponse::from(app),
|
||||
client_secret: raw_secret,
|
||||
};
|
||||
|
||||
Ok((no_store_headers(), Json(resp)))
|
||||
}
|
||||
|
||||
/// GET /api/v1/applications/:id
|
||||
@@ -90,9 +134,13 @@ pub async fn get_application(
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateApplicationRequest {
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub description: Option<String>,
|
||||
pub redirect_urls: Option<Vec<String>>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
@@ -103,21 +151,221 @@ pub async fn update_application(
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateApplicationRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let app = identity::update(
|
||||
&state.provider,
|
||||
&id,
|
||||
&body.name,
|
||||
&body.slug,
|
||||
body.description.as_deref(),
|
||||
body.redirect_urls,
|
||||
body.scopes,
|
||||
body.enabled,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let app = identity::update(&state.provider, &id, &body.name, &body.slug, body.enabled).await?;
|
||||
Ok(Json(
|
||||
json!({ "application": ApplicationResponse::from(app) }),
|
||||
))
|
||||
}
|
||||
|
||||
/// POST /api/v1/applications/:id/secret
|
||||
pub async fn rotate_application_secret(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<(HeaderMap, Json<RotateSecretResponse>)> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let raw_secret =
|
||||
identity::rotate_secret(&state.provider, &id, Some(&auth.user.id), None, None).await?;
|
||||
|
||||
let resp = RotateSecretResponse {
|
||||
client_secret: raw_secret,
|
||||
};
|
||||
|
||||
Ok((no_store_headers(), Json(resp)))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/applications/:id
|
||||
pub async fn delete_application(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
identity::delete(&state.provider, &id).await?;
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
identity::delete(&state.provider, &id, Some(&auth.user.id), None, None).await?;
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
// ── Application membership ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ApplicationMemberResponse {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub user_status: String,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl ApplicationMemberResponse {
|
||||
fn from_member(member: ApplicationMember, username: String, user_status: String) -> Self {
|
||||
Self {
|
||||
id: member.id,
|
||||
application_id: member.application_id,
|
||||
user_id: member.user_id,
|
||||
username,
|
||||
user_status,
|
||||
role: member.role,
|
||||
enabled: member.enabled,
|
||||
created_at: member.created_at,
|
||||
updated_at: member.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_member(
|
||||
state: &AppState,
|
||||
member: ApplicationMember,
|
||||
) -> Result<ApplicationMemberResponse> {
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&member.user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let (username, user_status) = match user {
|
||||
Some(u) => (
|
||||
u.username,
|
||||
if u.status == 1 {
|
||||
"active".to_string()
|
||||
} else if u.status == 3 {
|
||||
"locked".to_string()
|
||||
} else {
|
||||
"disabled".to_string()
|
||||
},
|
||||
),
|
||||
None => ("unknown".to_string(), "unknown".to_string()),
|
||||
};
|
||||
|
||||
Ok(ApplicationMemberResponse::from_member(
|
||||
member,
|
||||
username,
|
||||
user_status,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateMemberRequest {
|
||||
pub user_id: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UpdateMemberRequest {
|
||||
pub role: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// GET /api/v1/applications/:id/members
|
||||
pub async fn list_application_members(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let members_list = members::list_by_application(&state.provider, &id).await?;
|
||||
let mut views = Vec::with_capacity(members_list.len());
|
||||
for m in members_list {
|
||||
views.push(enrich_member(&state, m).await?);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "members": views })))
|
||||
}
|
||||
|
||||
/// POST /api/v1/applications/:id/members
|
||||
pub async fn add_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<CreateMemberRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
if body.user_id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("user_id is required".into()));
|
||||
}
|
||||
|
||||
let member = members::add(
|
||||
&state.provider,
|
||||
&id,
|
||||
body.user_id.trim(),
|
||||
body.role.as_deref(),
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let view = enrich_member(&state, member).await?;
|
||||
Ok(Json(json!({ "member": view })))
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/applications/:id/members/:user_id
|
||||
pub async fn update_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
Json(body): Json<UpdateMemberRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
let member = members::update(
|
||||
&state.provider,
|
||||
&id,
|
||||
&user_id,
|
||||
body.role.as_deref(),
|
||||
body.enabled,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let view = enrich_member(&state, member).await?;
|
||||
Ok(Json(json!({ "member": view })))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/applications/:id/members/:user_id
|
||||
pub async fn remove_application_member(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, MANAGE_PERM).await?;
|
||||
|
||||
members::remove(
|
||||
&state.provider,
|
||||
&id,
|
||||
&user_id,
|
||||
Some(&auth.user.id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
+74
-10
@@ -56,6 +56,7 @@ pub struct AuditQuery {
|
||||
pub actor: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
@@ -80,10 +81,12 @@ pub async fn list_audit(
|
||||
actor_user_id: query.actor,
|
||||
action: query.action,
|
||||
resource_type: query.resource_type,
|
||||
resource_id: query.resource_id,
|
||||
severity: query.severity,
|
||||
since: query.since,
|
||||
until: query.until,
|
||||
search: query.q,
|
||||
success: query.success,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
@@ -92,19 +95,10 @@ pub async fn list_audit(
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let mut entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||
let entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if let Some(success) = query.success {
|
||||
entries.retain(|e| {
|
||||
let ok = !e.action.contains("fail")
|
||||
&& !e.action.contains("denied")
|
||||
&& e.severity != "critical";
|
||||
ok == success
|
||||
});
|
||||
}
|
||||
|
||||
let views: Vec<AuditLogResponse> = entries.into_iter().map(AuditLogResponse::from).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -114,3 +108,73 @@ pub async fn list_audit(
|
||||
"offset": offset,
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/audit/export
|
||||
pub async fn export_audit(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Query(query): Query<AuditQuery>,
|
||||
) -> Result<axum::response::Response> {
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
require(&state.provider, &auth.user.id, "audit:view").await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(5000).clamp(1, 5000);
|
||||
let offset = query.offset.unwrap_or(0).max(0);
|
||||
|
||||
let filter = AuditFilter {
|
||||
actor_user_id: query.actor,
|
||||
action: query.action,
|
||||
resource_type: query.resource_type,
|
||||
resource_id: query.resource_id,
|
||||
severity: query.severity,
|
||||
since: query.since,
|
||||
until: query.until,
|
||||
search: query.q,
|
||||
success: query.success,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
let entries = audit_repo::list_filtered(&state.provider, &filter)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let mut csv = String::from(
|
||||
"id,created_at,action,resource_type,resource_id,severity,success,actor_user_id,target_user_id,ip_address,user_agent,metadata_json\r\n",
|
||||
);
|
||||
for e in entries {
|
||||
let resp = AuditLogResponse::from(e);
|
||||
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
let line = format!(
|
||||
"{},{},{},{},{},{},{},{},{},{},{},{}\r\n",
|
||||
esc(&resp.id),
|
||||
esc(&resp.created_at),
|
||||
esc(&resp.action),
|
||||
esc(&resp.resource_type),
|
||||
esc(resp.resource_id.as_deref().unwrap_or("")),
|
||||
esc(&resp.severity),
|
||||
resp.success,
|
||||
esc(resp.actor_user_id.as_deref().unwrap_or("")),
|
||||
esc(resp.target_user_id.as_deref().unwrap_or("")),
|
||||
esc(resp.ip_address.as_deref().unwrap_or("")),
|
||||
esc(resp.user_agent.as_deref().unwrap_or("")),
|
||||
esc(resp.metadata_json.as_deref().unwrap_or("")),
|
||||
);
|
||||
csv.push_str(&line);
|
||||
}
|
||||
|
||||
let response = (
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"audit_export.csv\"",
|
||||
),
|
||||
],
|
||||
csv,
|
||||
)
|
||||
.into_response();
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
+12
-12
@@ -68,10 +68,10 @@ pub async fn login(
|
||||
}
|
||||
|
||||
// Rate limit check (per IP)
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.check(ip_addr)?;
|
||||
}
|
||||
if let Some(ip_str) = &ctx.ip_address
|
||||
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||
{
|
||||
state.rate_limiter.check(ip_addr)?;
|
||||
}
|
||||
|
||||
// Look up user — always run comparable work on failure paths (timing).
|
||||
@@ -103,10 +103,10 @@ pub async fn login(
|
||||
|
||||
if !is_authed {
|
||||
record_login_failure(&state, body.username.trim(), ip, ctx.user_agent.as_deref()).await;
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.record_failure(ip_addr);
|
||||
}
|
||||
if let Some(ip_str) = &ctx.ip_address
|
||||
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||
{
|
||||
state.rate_limiter.record_failure(ip_addr);
|
||||
}
|
||||
// Non-enumerating error for both unknown user and bad password.
|
||||
return Err(AppError::InvalidCredentials);
|
||||
@@ -118,10 +118,10 @@ pub async fn login(
|
||||
};
|
||||
|
||||
// Clear rate limit on success
|
||||
if let Some(ip_str) = &ctx.ip_address {
|
||||
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||
state.rate_limiter.record_success(ip_addr);
|
||||
}
|
||||
if let Some(ip_str) = &ctx.ip_address
|
||||
&& let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>()
|
||||
{
|
||||
state.rate_limiter.record_success(ip_addr);
|
||||
}
|
||||
|
||||
// Session fixation mitigation: revoke prior sessions + refresh tokens.
|
||||
|
||||
+31
-1
@@ -1,7 +1,7 @@
|
||||
use axum::http::{HeaderName, Method, header};
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{delete, get, post, put},
|
||||
routing::{delete, get, patch, post, put},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
|
||||
|
||||
@@ -40,6 +40,18 @@ pub fn build(state: AppState) -> Router {
|
||||
.patch(tenants::update_tenant)
|
||||
.delete(tenants::delete_tenant),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/users",
|
||||
get(tenants::list_tenant_users).post(tenants::assign_tenant_user),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/users/{user_id}",
|
||||
delete(tenants::remove_tenant_user),
|
||||
)
|
||||
.route(
|
||||
"/tenants/{id}/applications",
|
||||
get(tenants::list_tenant_applications),
|
||||
)
|
||||
// Users
|
||||
.route("/users", get(users::list_users).post(users::create_user))
|
||||
.route(
|
||||
@@ -54,6 +66,10 @@ pub fn build(state: AppState) -> Router {
|
||||
get(users::list_user_roles).post(roles::assign_user_role),
|
||||
)
|
||||
.route("/users/{id}/roles/{role}", delete(roles::remove_user_role))
|
||||
.route(
|
||||
"/users/{id}/applications",
|
||||
get(users::list_user_applications),
|
||||
)
|
||||
// Roles
|
||||
.route("/roles", get(roles::list_roles).post(roles::create_role))
|
||||
.route(
|
||||
@@ -82,6 +98,19 @@ pub fn build(state: AppState) -> Router {
|
||||
.patch(applications::update_application)
|
||||
.delete(applications::delete_application),
|
||||
)
|
||||
.route(
|
||||
"/applications/{id}/secret",
|
||||
post(applications::rotate_application_secret),
|
||||
)
|
||||
.route(
|
||||
"/applications/{id}/members",
|
||||
get(applications::list_application_members).post(applications::add_application_member),
|
||||
)
|
||||
.route(
|
||||
"/applications/{id}/members/{user_id}",
|
||||
patch(applications::update_application_member)
|
||||
.delete(applications::remove_application_member),
|
||||
)
|
||||
// Service accounts
|
||||
.route(
|
||||
"/service-accounts",
|
||||
@@ -100,6 +129,7 @@ pub fn build(state: AppState) -> Router {
|
||||
)
|
||||
// Audit
|
||||
.route("/audit", get(audit::list_audit))
|
||||
.route("/audit/export", get(audit::export_audit))
|
||||
// Sessions
|
||||
.route("/sessions", get(sessions::list_sessions))
|
||||
.route("/sessions/others", delete(sessions::terminate_others))
|
||||
|
||||
+6
-6
@@ -103,12 +103,12 @@ pub async fn terminate_session(
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
// If the user is trying to terminate the current session, disallow it
|
||||
if let Some(current_id) = auth.session_id.as_deref() {
|
||||
if id == current_id {
|
||||
return Err(AppError::InvalidInput(
|
||||
"Cannot terminate current session".into(),
|
||||
));
|
||||
}
|
||||
if let Some(current_id) = auth.session_id.as_deref()
|
||||
&& id == current_id
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"Cannot terminate current session".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Admins can terminate any session, users can only terminate their own
|
||||
|
||||
@@ -53,6 +53,12 @@ pub async fn create_tenant(
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if let Some(ref s) = body.slug
|
||||
&& !s.trim().is_empty()
|
||||
{
|
||||
crate::identity::slug::validate_slug(s)?;
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let tenant = state
|
||||
.provider
|
||||
@@ -118,6 +124,12 @@ pub async fn update_tenant(
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if let Some(ref s) = body.slug
|
||||
&& !s.trim().is_empty()
|
||||
{
|
||||
crate::identity::slug::validate_slug(s)?;
|
||||
}
|
||||
|
||||
state
|
||||
.provider
|
||||
.tenants()
|
||||
@@ -183,3 +195,153 @@ pub async fn delete_tenant(
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tenants/:id/users
|
||||
pub async fn list_tenant_users(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
let users = state.provider.users().list(&id).await?;
|
||||
let views: Vec<crate::api::users::UserResponse> = users
|
||||
.into_iter()
|
||||
.map(crate::api::users::UserResponse::from)
|
||||
.collect();
|
||||
Ok(Json(json!({ "users": views })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssignTenantUserRequest {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// POST /api/v1/tenants/:id/users
|
||||
pub async fn assign_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<AssignTenantUserRequest>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
let _tenant = state
|
||||
.provider
|
||||
.tenants()
|
||||
.find_by_id(&id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&body.user_id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
if from_tenant_id == id {
|
||||
return Ok(Json(
|
||||
json!({ "user": crate::api::users::UserResponse::from(user) }),
|
||||
));
|
||||
}
|
||||
|
||||
if state
|
||||
.provider
|
||||
.users()
|
||||
.username_exists(&id, &user.username)
|
||||
.await?
|
||||
{
|
||||
return Err(crate::error::AppError::Conflict(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
state
|
||||
.provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
&id,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let updated_user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
Ok(Json(
|
||||
json!({ "user": crate::api::users::UserResponse::from(updated_user) }),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/tenants/:id/users/:user_id
|
||||
pub async fn remove_tenant_user(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
ctx: AuditContext,
|
||||
Path((id, user_id)): Path<(String, String)>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
if id == Tenant::DEFAULT_ID {
|
||||
return Err(crate::error::AppError::InvalidInput(
|
||||
"users cannot be moved out of default tenant without specifying a destination tenant"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user = state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or(crate::error::AppError::NotFound)?;
|
||||
|
||||
if user.tenant_id != id {
|
||||
return Err(crate::error::AppError::InvalidInput(
|
||||
"user does not belong to the specified tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let target_tenant = Tenant::DEFAULT_ID;
|
||||
|
||||
state
|
||||
.provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
target_tenant,
|
||||
Some(&auth.user.id),
|
||||
ctx.ip_address.as_deref(),
|
||||
ctx.user_agent.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tenants/:id/applications
|
||||
pub async fn list_tenant_applications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(&state.provider, &auth.user.id, "roles:manage").await?;
|
||||
|
||||
let apps = state.provider.applications().list(&id).await?;
|
||||
let views: Vec<crate::api::applications::ApplicationResponse> = apps
|
||||
.into_iter()
|
||||
.map(crate::api::applications::ApplicationResponse::from)
|
||||
.collect();
|
||||
Ok(Json(json!({ "applications": views })))
|
||||
}
|
||||
+17
-17
@@ -26,13 +26,13 @@ pub fn ui_dist_dir() -> PathBuf {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
for rel in ["ui/dist", "../ui/dist", "../../ui/dist"] {
|
||||
let candidate = dir.join(rel);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(dir) = exe.parent()
|
||||
{
|
||||
for rel in ["ui/dist", "../ui/dist", "../../ui/dist"] {
|
||||
let candidate = dir.join(rel);
|
||||
if candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,16 +52,6 @@ fn is_static_asset(path: &str) -> bool {
|
||||
|
||||
/// Serve a static file from the UI dist dir, or SPA fallback for app routes.
|
||||
pub async fn serve_ui(uri: Uri) -> Response {
|
||||
let dist = ui_dist_dir();
|
||||
if !dist.exists() {
|
||||
return missing_ui_page().into_response();
|
||||
}
|
||||
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
if path.starts_with("api/") || path == "health" || path == "version" {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
// Security Hardening: Reject & sanitize any GET request containing credentials in query string.
|
||||
if let Some(query) = uri.query() {
|
||||
let q_lower = query.to_ascii_lowercase();
|
||||
@@ -84,6 +74,16 @@ pub async fn serve_ui(uri: Uri) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
let dist = ui_dist_dir();
|
||||
if !dist.exists() {
|
||||
return missing_ui_page().into_response();
|
||||
}
|
||||
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
if path.starts_with("api/") || path == "health" || path == "version" {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
// Normalize and reject path traversal
|
||||
if path.contains("..") {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
|
||||
+80
-2
@@ -9,7 +9,7 @@ use crate::{
|
||||
db::models::Tenant,
|
||||
db::models::{User, UserStatus},
|
||||
error::{AppError, Result},
|
||||
identity::users as identity,
|
||||
identity::{application_members as members, users as identity},
|
||||
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -20,6 +20,7 @@ use crate::{
|
||||
pub struct UserResponse {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub tenant_id: String,
|
||||
pub status: String,
|
||||
pub last_login_at: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -29,8 +30,9 @@ pub struct UserResponse {
|
||||
impl From<User> for UserResponse {
|
||||
fn from(u: User) -> Self {
|
||||
Self {
|
||||
id: u.id,
|
||||
id: u.id.clone(),
|
||||
username: u.username,
|
||||
tenant_id: u.tenant_id,
|
||||
status: UserStatus::from_i32(u.status).to_string(),
|
||||
last_login_at: u.last_login_at,
|
||||
created_at: u.created_at,
|
||||
@@ -215,3 +217,79 @@ pub async fn list_user_roles(
|
||||
}).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/users/:id/applications
|
||||
///
|
||||
/// Reverse lookup: list applications assigned to a user via membership.
|
||||
/// Requires `applications:manage` (membership administration).
|
||||
pub async fn list_user_applications(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>> {
|
||||
require(
|
||||
&state.provider,
|
||||
&auth.user.id,
|
||||
crate::api::applications::MANAGE_PERM,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let memberships = members::list_by_user(&state.provider, &id).await?;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserApplicationView {
|
||||
id: String,
|
||||
application_id: String,
|
||||
user_id: String,
|
||||
role: String,
|
||||
enabled: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
application_name: String,
|
||||
application_slug: String,
|
||||
application_enabled: bool,
|
||||
client_id: String,
|
||||
credentials_configured: bool,
|
||||
}
|
||||
|
||||
let mut views = Vec::with_capacity(memberships.len());
|
||||
for m in memberships {
|
||||
let app = state
|
||||
.provider
|
||||
.applications()
|
||||
.find_by_id(&m.application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let (name, slug, app_enabled, client_id, credentials_configured) = match app {
|
||||
Some(a) => {
|
||||
let credentials_configured = a.has_credentials();
|
||||
(
|
||||
a.name,
|
||||
a.slug.unwrap_or_default(),
|
||||
a.enabled,
|
||||
a.client_id,
|
||||
credentials_configured,
|
||||
)
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
|
||||
views.push(UserApplicationView {
|
||||
id: m.id,
|
||||
application_id: m.application_id,
|
||||
user_id: m.user_id,
|
||||
role: m.role,
|
||||
enabled: m.enabled,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
application_name: name,
|
||||
application_slug: slug,
|
||||
application_enabled: app_enabled,
|
||||
client_id,
|
||||
credentials_configured,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "applications": views })))
|
||||
}
|
||||
+17
-16
@@ -566,10 +566,10 @@ async fn cmd_init(
|
||||
let sqlite_path = config.database.sqlite_path();
|
||||
let db_path = std::path::Path::new(&sqlite_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)?;
|
||||
}
|
||||
if let Some(parent) = db_path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Create state directory
|
||||
@@ -664,10 +664,11 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
|
||||
let sqlite_path = config.database.sqlite_path();
|
||||
let db_path = std::path::Path::new(&sqlite_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 Some(parent) = db_path.parent()
|
||||
&& !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");
|
||||
@@ -892,10 +893,10 @@ async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
@@ -958,10 +959,10 @@ async fn cmd_restore(config: &Config, path: &std::path::Path) -> anyhow::Result<
|
||||
crate::config::DatabaseBackend::Sqlite => {
|
||||
let sqlite_path = config.database.sqlite_path();
|
||||
let target_path = std::path::Path::new(&sqlite_path);
|
||||
if let Some(parent) = target_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if let Some(parent) = target_path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(path, target_path).with_context(|| {
|
||||
format!("failed to restore backup to {}", target_path.display())
|
||||
|
||||
+16
-16
@@ -293,13 +293,13 @@ impl Default for ShutdownConfig {
|
||||
// ── 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();
|
||||
}
|
||||
if let Some(stripped) = path.strip_prefix("~/")
|
||||
&& let Ok(home) = std::env::var("HOME")
|
||||
{
|
||||
return Path::new(&home)
|
||||
.join(stripped)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
@@ -312,11 +312,11 @@ impl Config {
|
||||
if let Some(ref mut path) = self.database.path {
|
||||
*path = resolve_home_path(path);
|
||||
}
|
||||
if let Some(ref mut url) = self.database.url {
|
||||
if let Some(stripped) = url.strip_prefix("sqlite://") {
|
||||
let clean = resolve_home_path(stripped);
|
||||
*url = format!("sqlite://{clean}");
|
||||
}
|
||||
if let Some(ref mut url) = self.database.url
|
||||
&& let Some(stripped) = url.strip_prefix("sqlite://")
|
||||
{
|
||||
let clean = resolve_home_path(stripped);
|
||||
*url = format!("sqlite://{clean}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +372,10 @@ impl Config {
|
||||
|
||||
/// Default user configuration path (~/.config/nx9-auth/config.toml)
|
||||
pub fn default_user_config_path() -> Option<PathBuf> {
|
||||
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(xdg) = std::env::var("XDG_CONFIG_HOME")
|
||||
&& !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"));
|
||||
|
||||
@@ -1,4 +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);
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -1,35 +1,40 @@
|
||||
-- ── Roles ────────────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO roles (id, name, description) VALUES
|
||||
INSERT 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');
|
||||
('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Permissions ───────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
|
||||
INSERT 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');
|
||||
('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Admin role gets all permissions ──────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Editor role permissions ───────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||
INSERT 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');
|
||||
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Default applications ──────────────────────────────────────────────────────
|
||||
|
||||
INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES
|
||||
INSERT 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);
|
||||
('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- ── Add Application Credentials Columns & Permissions (PostgreSQL) ───────────
|
||||
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS client_id TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS client_secret_hash TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS redirect_uris TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS scopes TEXT;
|
||||
|
||||
-- Backfill client_id for existing applications
|
||||
UPDATE applications SET client_id = 'nx9_app_' || replace(id, '-', '') WHERE client_id IS NULL;
|
||||
|
||||
-- Create unique index on client_id
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_client_id ON applications(client_id);
|
||||
|
||||
-- Seed applications:manage permission
|
||||
INSERT INTO permissions (id, name, description) VALUES
|
||||
('20000000-0000-0000-0000-000000000008', 'applications:manage', 'Manage registered application credentials')
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Grant permission to admin role
|
||||
INSERT INTO role_permissions (role_id, permission_id) VALUES
|
||||
('10000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000008')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ── Application Credentials Production Hardening (PostgreSQL) ───────────────
|
||||
|
||||
-- Backfill any remaining applications with client_id if missing
|
||||
UPDATE applications SET client_id = 'nx9_app_' || replace(id, '-', '') WHERE client_id IS NULL;
|
||||
|
||||
-- Enforce NOT NULL constraint on client_id
|
||||
ALTER TABLE applications ALTER COLUMN client_id SET NOT NULL;
|
||||
|
||||
-- Ensure unique index on client_id exists
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_client_id ON applications(client_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Application membership: assign existing NX9-Auth users to registered applications.
|
||||
-- Membership roles (owner/admin/member) are lightweight metadata only and do not
|
||||
-- grant global RBAC permissions such as applications:manage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_members (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'admin', 'member')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
|
||||
UNIQUE (application_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_application
|
||||
ON application_members(application_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_user
|
||||
ON application_members(user_id);
|
||||
@@ -1,4 +1,4 @@
|
||||
-- nx9-auth: Global Slugs implementation
|
||||
-- nx9-auth: Global Slugs implementation (PostgreSQL)
|
||||
-- A unified registry for slugs across all resources (tenant, user, group, role, app, etc.)
|
||||
-- Ensures global uniqueness and immutable references.
|
||||
|
||||
@@ -7,22 +7,21 @@ CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
entity_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team'
|
||||
entity_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id);
|
||||
|
||||
-- Add slug column to existing tables for quick lookup and joins
|
||||
ALTER TABLE tenants ADD COLUMN slug TEXT;
|
||||
ALTER TABLE users ADD COLUMN slug TEXT;
|
||||
ALTER TABLE roles ADD COLUMN slug TEXT;
|
||||
ALTER TABLE permissions ADD COLUMN slug TEXT;
|
||||
ALTER TABLE applications ADD COLUMN slug TEXT;
|
||||
ALTER TABLE service_accounts ADD COLUMN slug TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE permissions ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE applications ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
ALTER TABLE service_accounts ADD COLUMN IF NOT EXISTS slug TEXT;
|
||||
|
||||
-- We will backfill slugs in Rust on startup or through a data migration script,
|
||||
-- or we can backfill basic ones here:
|
||||
-- Backfill basic slugs:
|
||||
UPDATE tenants SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||
UPDATE users SET slug = lower(username) WHERE slug IS NULL;
|
||||
UPDATE roles SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||
@@ -30,21 +29,27 @@ UPDATE permissions SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||
UPDATE applications SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||
UPDATE service_accounts SET slug = lower(replace(name, ' ', '-')) WHERE slug IS NULL;
|
||||
|
||||
-- Insert the backfilled slugs into the registry
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL;
|
||||
-- Insert backfilled slugs into registry
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id FROM tenants WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL;
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'user', id, tenant_id FROM users WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'role', id, tenant_id FROM roles WHERE slug IS NOT NULL;
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'role', id, '00000000-0000-0000-0000-000000000001' FROM roles WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'permission', id, tenant_id FROM permissions WHERE slug IS NOT NULL;
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'permission', id, '00000000-0000-0000-0000-000000000001' FROM permissions WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL;
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id FROM applications WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT OR IGNORE INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL;
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'service_account', id, tenant_id FROM service_accounts WHERE slug IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- nx9-auth: Global Slugs Hardening & Parity Alignment (PostgreSQL)
|
||||
-- Ensures unified global_slugs registry table, indices, and legacy data integrity.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id);
|
||||
|
||||
-- Explicit backfill for tenants that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id
|
||||
FROM tenants
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
|
||||
-- Explicit backfill for applications that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id
|
||||
FROM applications
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ── Add Application Credentials Columns & Permissions (SQLite) ────────────────
|
||||
|
||||
ALTER TABLE applications ADD COLUMN client_id TEXT;
|
||||
ALTER TABLE applications ADD COLUMN description TEXT;
|
||||
ALTER TABLE applications ADD COLUMN client_secret_hash TEXT;
|
||||
ALTER TABLE applications ADD COLUMN redirect_uris TEXT;
|
||||
ALTER TABLE applications ADD COLUMN scopes TEXT;
|
||||
|
||||
-- Backfill client_id for existing applications
|
||||
UPDATE applications SET client_id = 'nx9_app_' || replace(id, '-', '') WHERE client_id IS NULL;
|
||||
|
||||
-- Create unique index on client_id
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_client_id ON applications(client_id);
|
||||
|
||||
-- Seed applications:manage permission
|
||||
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
|
||||
('20000000-0000-0000-0000-000000000008', 'applications:manage', 'Manage registered application credentials');
|
||||
|
||||
-- Grant permission to admin role
|
||||
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||
('10000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000008');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ── Application Credentials Production Hardening (SQLite) ───────────────────
|
||||
|
||||
-- Backfill any remaining applications with client_id if missing
|
||||
UPDATE applications SET client_id = 'nx9_app_' || replace(id, '-', '') WHERE client_id IS NULL;
|
||||
|
||||
-- Ensure unique index on client_id exists
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_client_id ON applications(client_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Application membership: assign existing NX9-Auth users to registered applications.
|
||||
-- Membership roles (owner/admin/member) are lightweight metadata only and do not
|
||||
-- grant global RBAC permissions such as applications:manage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS application_members (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
application_id TEXT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (role IN ('owner', 'admin', 'member')),
|
||||
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 (application_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_application
|
||||
ON application_members(application_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_application_members_user
|
||||
ON application_members(user_id);
|
||||
@@ -0,0 +1,27 @@
|
||||
-- nx9-auth: Global Slugs Hardening & Parity Alignment
|
||||
-- Ensures unified global_slugs registry table, indices, and legacy data integrity.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_tenant ON global_slugs(tenant_id);
|
||||
|
||||
-- Explicit backfill for tenants that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'tenant', id, id
|
||||
FROM tenants
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
|
||||
-- Explicit backfill for applications that are not yet in global_slugs.
|
||||
-- Fails immediately if cross-resource slug collision exists.
|
||||
INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id)
|
||||
SELECT slug, 'application', id, tenant_id
|
||||
FROM applications
|
||||
WHERE slug IS NOT NULL AND slug NOT IN (SELECT slug FROM global_slugs);
|
||||
+12
-12
@@ -57,12 +57,12 @@ pub async fn init_provider(
|
||||
#[cfg(feature = "sqlite")]
|
||||
DatabaseBackend::Sqlite => {
|
||||
let path = config.database.sqlite_path();
|
||||
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())
|
||||
})?;
|
||||
}
|
||||
if let Some(parent) = std::path::Path::new(&path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create database directory: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let max_conn = config.database.max_connections.unwrap_or(16);
|
||||
@@ -177,12 +177,12 @@ pub async fn init_provider(
|
||||
/// Helper function to create an SQLite pool for legacy CLI commands or tests.
|
||||
#[cfg(feature = "sqlite")]
|
||||
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
|
||||
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())
|
||||
})?;
|
||||
}
|
||||
if let Some(parent) = std::path::Path::new(path).parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("failed to create database directory: {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let url = if path.starts_with("sqlite://") {
|
||||
path.to_string()
|
||||
|
||||
@@ -8,9 +8,51 @@ pub struct Application {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub slug: Option<String>,
|
||||
pub client_id: String,
|
||||
pub enabled: bool,
|
||||
pub client_secret_hash: Option<String>,
|
||||
pub redirect_uris: Option<String>,
|
||||
pub scopes: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
/// Return effective client ID string.
|
||||
pub fn get_client_id(&self) -> &str {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
/// Parse configured redirect URLs.
|
||||
pub fn redirect_urls(&self) -> Vec<String> {
|
||||
let Some(raw) = &self.redirect_uris else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Ok(vec) = serde_json::from_str::<Vec<String>>(raw) {
|
||||
return vec;
|
||||
}
|
||||
raw.split([',', '\n', ' '])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse configured scopes.
|
||||
pub fn scopes(&self) -> Vec<String> {
|
||||
let Some(raw) = &self.scopes else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Ok(vec) = serde_json::from_str::<Vec<String>>(raw) {
|
||||
return vec;
|
||||
}
|
||||
raw.split([',', ' '])
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return true if application has configured client secret credentials.
|
||||
pub fn has_credentials(&self) -> bool {
|
||||
self.client_secret_hash.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::FromRow;
|
||||
|
||||
/// Allowed application membership roles (lightweight metadata only).
|
||||
///
|
||||
/// These do **not** grant global NX9-Auth RBAC permissions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ApplicationMembershipRole {
|
||||
Owner,
|
||||
Admin,
|
||||
#[default]
|
||||
Member,
|
||||
}
|
||||
|
||||
impl ApplicationMembershipRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Owner => "owner",
|
||||
Self::Admin => "admin",
|
||||
Self::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a role string. Returns `None` for invalid values.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"owner" => Some(Self::Owner),
|
||||
"admin" => Some(Self::Admin),
|
||||
"member" => Some(Self::Member),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApplicationMembershipRole {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Assignment of an existing NX9-Auth user to a registered application.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||
pub struct ApplicationMember {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
impl ApplicationMember {
|
||||
pub fn membership_role(&self) -> Option<ApplicationMembershipRole> {
|
||||
ApplicationMembershipRole::parse(&self.role)
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,12 @@ pub struct AuditFilter {
|
||||
pub actor_user_id: Option<String>,
|
||||
pub action: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_id: Option<String>,
|
||||
pub severity: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub until: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub success: Option<bool>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct GlobalSlug {
|
||||
pub slug: String,
|
||||
pub entity_type: String,
|
||||
pub entity_id: String,
|
||||
pub tenant_id: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod api_token;
|
||||
pub mod application;
|
||||
pub mod application_member;
|
||||
pub mod audit_log;
|
||||
pub mod global_slug;
|
||||
pub mod group;
|
||||
pub mod permission;
|
||||
pub mod refresh_token;
|
||||
@@ -12,7 +14,9 @@ pub mod user;
|
||||
|
||||
pub use api_token::ApiToken;
|
||||
pub use application::Application;
|
||||
pub use application_member::{ApplicationMember, ApplicationMembershipRole};
|
||||
pub use audit_log::{AuditFilter, AuditLog, AuditSeverity};
|
||||
pub use global_slug::GlobalSlug;
|
||||
pub use group::Group;
|
||||
#[allow(unused_imports)]
|
||||
pub use permission::Permission;
|
||||
|
||||
@@ -18,6 +18,8 @@ pub trait DatabaseProvider: Send + Sync {
|
||||
fn tokens(&self) -> Box<dyn TokensRepository>;
|
||||
fn tenants(&self) -> Box<dyn TenantsRepository>;
|
||||
fn groups(&self) -> Box<dyn GroupsRepository>;
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository>;
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "sqlite")]
|
||||
@@ -112,6 +114,20 @@ impl DatabaseProvider for SqliteProvider {
|
||||
},
|
||||
)
|
||||
}
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::sqlite::application_members::SqliteApplicationMembersRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::sqlite::global_slugs::SqliteGlobalSlugsRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
@@ -206,4 +222,18 @@ impl DatabaseProvider for PostgresProvider {
|
||||
},
|
||||
)
|
||||
}
|
||||
fn application_members(&self) -> Box<dyn ApplicationMembersRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::postgres::application_members::PostgresApplicationMembersRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
fn global_slugs(&self) -> Box<dyn GlobalSlugsRepository> {
|
||||
Box::new(
|
||||
crate::db::repository::postgres::global_slugs::PostgresGlobalSlugsRepository {
|
||||
pool: self.pool.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use crate::db::models::ApplicationMember;
|
||||
use crate::db::repository::traits::ApplicationMembersRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresApplicationMembersRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationMembersRepository for PostgresApplicationMembersRepository {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = $1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES ($1, $2, $3, $4, TRUE)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES ($1, $2, $3, $4, TRUE)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = $1,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE application_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let existing_member: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = $1 AND user_id = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing_member.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = $1")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = $1 AND user_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,38 @@
|
||||
use crate::db::models::Application;
|
||||
use crate::db::repository::postgres::global_slugs::{
|
||||
release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres,
|
||||
};
|
||||
use crate::db::repository::traits::ApplicationsRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::db::models::Application;
|
||||
|
||||
pub struct PostgresApplicationsRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
async fn create(
|
||||
async fn create_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
client_id: &str,
|
||||
client_secret_hash: Option<&str>,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<Application, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>(
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, slug, "application", id, tenant_id).await?;
|
||||
|
||||
let app = sqlx::query_as::<_, Application>(
|
||||
r#"
|
||||
INSERT INTO applications (id, tenant_id, name, slug)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
@@ -28,8 +40,43 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
.bind(tenant_id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.bind(client_id)
|
||||
.bind(client_secret_hash)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
@@ -39,6 +86,13 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_by_client_id(&self, client_id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE client_id = $1")
|
||||
.bind(client_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = $1")
|
||||
.bind(id)
|
||||
@@ -59,10 +113,72 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
sqlx::query(
|
||||
"UPDATE applications SET enabled = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_secret_hash(&self, id: &str, secret_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE applications SET client_secret_hash = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
)
|
||||
.bind(secret_hash)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rotate_secret_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
secret_hash: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE applications SET client_secret_hash = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
)
|
||||
.bind(secret_hash)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,30 +187,79 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, slug = $2, enabled = $3,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $4
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, description = $2, redirect_uris = $3, scopes = $4, enabled = $5,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $6
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, slug, "application", id, &existing.tenant_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = $1, slug = $2, description = $3, redirect_uris = $4, scopes = $5, enabled = $6,
|
||||
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
WHERE id = $7
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_postgres(&mut tx, existing_slug_str, "application", id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_postgres(&mut tx, "application", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM applications WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::db::models::{AuditFilter, AuditLog};
|
||||
use crate::db::repository::traits::AuditRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::db::models::{AuditFilter, AuditLog};
|
||||
|
||||
pub struct PostgresAuditRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
@@ -31,29 +30,13 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
user_agent: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
) -> Result<AuditLog, sqlx::Error> {
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
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(&self.pool)
|
||||
.await
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 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(&self.pool).await
|
||||
}
|
||||
|
||||
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
@@ -64,81 +47,71 @@ impl AuditRepository for PostgresAuditRepository {
|
||||
}
|
||||
|
||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
// Build a dynamic but simple filter using COALESCE-style optional matches.
|
||||
// Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None.
|
||||
let search_like = filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||
|
||||
sqlx::query_as::<_, AuditLog>(
|
||||
r#"
|
||||
SELECT * FROM audit_logs
|
||||
WHERE ($1::text IS NULL OR actor_user_id = $1)
|
||||
AND ($2::text IS NULL OR action = $2)
|
||||
AND ($3::text IS NULL OR resource_type = $3)
|
||||
AND ($4::text IS NULL OR severity = $4)
|
||||
AND ($5::text IS NULL OR created_at >= $5)
|
||||
AND ($6::text IS NULL OR created_at <= $6)
|
||||
AND (
|
||||
$7::text IS NULL
|
||||
OR action LIKE $7 ESCAPE '\'
|
||||
OR resource_type LIKE $7 ESCAPE '\'
|
||||
OR resource_id LIKE $7 ESCAPE '\'
|
||||
OR ip_address LIKE $7 ESCAPE '\'
|
||||
OR metadata_json LIKE $7 ESCAPE '\'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $8 OFFSET $9
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
let search_like = search_like(filter);
|
||||
sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.resource_id.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.success)
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
|
||||
let search_like = filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM audit_logs
|
||||
WHERE ($1::text IS NULL OR actor_user_id = $1)
|
||||
AND ($2::text IS NULL OR action = $2)
|
||||
AND ($3::text IS NULL OR resource_type = $3)
|
||||
AND ($4::text IS NULL OR severity = $4)
|
||||
AND ($5::text IS NULL OR created_at >= $5)
|
||||
AND ($6::text IS NULL OR created_at <= $6)
|
||||
AND (
|
||||
$7::text IS NULL
|
||||
OR action LIKE $7 ESCAPE '\'
|
||||
OR resource_type LIKE $7 ESCAPE '\'
|
||||
OR resource_id LIKE $7 ESCAPE '\'
|
||||
OR ip_address LIKE $7 ESCAPE '\'
|
||||
OR metadata_json LIKE $7 ESCAPE '\'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
let search_like = search_like(filter);
|
||||
let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.resource_id.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.success)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn search_like(filter: &AuditFilter) -> Option<String> {
|
||||
filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")))
|
||||
}
|
||||
|
||||
const FILTER_LIST_SQL: &str = r#"
|
||||
SELECT * FROM audit_logs
|
||||
WHERE ($1::text IS NULL OR actor_user_id = $1)
|
||||
AND ($2::text IS NULL OR action = $2)
|
||||
AND ($3::text IS NULL OR resource_type = $3)
|
||||
AND ($4::text IS NULL OR resource_id = $4)
|
||||
AND ($5::text IS NULL OR severity = $5)
|
||||
AND ($6::text IS NULL OR created_at >= $6)
|
||||
AND ($7::text IS NULL OR created_at <= $7)
|
||||
AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\')
|
||||
AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
|
||||
ORDER BY created_at DESC LIMIT $10 OFFSET $11
|
||||
"#;
|
||||
|
||||
const FILTER_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*) FROM audit_logs
|
||||
WHERE ($1::text IS NULL OR actor_user_id = $1)
|
||||
AND ($2::text IS NULL OR action = $2)
|
||||
AND ($3::text IS NULL OR resource_type = $3)
|
||||
AND ($4::text IS NULL OR resource_id = $4)
|
||||
AND ($5::text IS NULL OR severity = $5)
|
||||
AND ($6::text IS NULL OR created_at >= $6)
|
||||
AND ($7::text IS NULL OR created_at <= $7)
|
||||
AND ($8::text IS NULL OR action LIKE $8 ESCAPE '\' OR resource_type LIKE $8 ESCAPE '\' OR resource_id LIKE $8 ESCAPE '\' OR ip_address LIKE $8 ESCAPE '\' OR metadata_json LIKE $8 ESCAPE '\')
|
||||
AND ($9::boolean IS NULL OR ($9::boolean = TRUE AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR ($9::boolean = FALSE AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
|
||||
"#;
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::db::models::GlobalSlug;
|
||||
use crate::db::repository::traits::GlobalSlugsRepository;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresGlobalSlugsRepository {
|
||||
pub pool: PgPool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GlobalSlugsRepository for PostgresGlobalSlugsRepository {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error> {
|
||||
sqlx::query_as::<_, GlobalSlug>(
|
||||
"SELECT slug, entity_type, entity_id, tenant_id, created_at::text FROM global_slugs WHERE slug = $1"
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reserve_slug_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
tenant_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) VALUES ($1, $2, $3, $4)"
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.bind(tenant_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_slug_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query("DELETE FROM global_slugs WHERE entity_type = $1 AND entity_id = $2")
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn release_slug_by_name_postgres(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM global_slugs WHERE slug = $1 AND entity_type = $2 AND entity_id = $3",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod audit;
|
||||
pub mod global_slugs;
|
||||
pub mod groups;
|
||||
pub mod permissions;
|
||||
pub mod refresh_tokens;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::db::models::Tenant;
|
||||
use crate::db::repository::postgres::global_slugs::{
|
||||
release_slug_by_name_postgres, release_slug_postgres, reserve_slug_postgres,
|
||||
};
|
||||
use crate::db::repository::traits::TenantsRepository;
|
||||
use crate::identity::slug::{slugify, validate_slug};
|
||||
|
||||
pub struct PostgresTenantsRepository {
|
||||
pub pool: PgPool,
|
||||
@@ -47,7 +51,17 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
name: &str,
|
||||
slug: Option<&str>,
|
||||
) -> Result<Tenant, sqlx::Error> {
|
||||
let slug = slug.unwrap_or(id);
|
||||
let final_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?,
|
||||
};
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
@@ -57,27 +71,68 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.bind(&final_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
||||
let slug = slug.unwrap_or(name);
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let target_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => existing.slug.clone().unwrap_or_else(|| id.to_string()),
|
||||
};
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if target_slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_postgres(&mut tx, &target_slug, "tenant", id, id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = $1, slug = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&target_slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_postgres(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,10 +154,16 @@ impl TenantsRepository for PostgresTenantsRepository {
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_postgres(&mut tx, "tenant", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM tenants WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,114 @@ impl UsersRepository for PostgresUsersRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 FOR UPDATE")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
if user.tenant_id == destination_tenant_id {
|
||||
tx.commit().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
|
||||
if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID {
|
||||
let admin_rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT ur.user_id FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin' FOR UPDATE",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let is_target_admin = admin_rows.iter().any(|r| r.0 == user_id);
|
||||
if is_target_admin && admin_rows.len() <= 1 {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"cannot reassign the last system administrator away from default tenant".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = $1")
|
||||
.bind(destination_tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if dest_exists.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let collision: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = $1 AND username = $2")
|
||||
.bind(destination_tenant_id)
|
||||
.bind(&user.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if collision.is_some() {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET tenant_id = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2 AND tenant_id = $3",
|
||||
)
|
||||
.bind(destination_tenant_id)
|
||||
.bind(user_id)
|
||||
.bind(&from_tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": destination_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(actor_id)
|
||||
.bind(Some(&user.id))
|
||||
.bind("user.tenant_reassigned")
|
||||
.bind("user")
|
||||
.bind(Some(&user.id))
|
||||
.bind("info")
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(&metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET password_hash = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
use crate::db::models::ApplicationMember;
|
||||
use crate::db::repository::traits::ApplicationMembersRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteApplicationMembersRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationMembersRepository for SqliteApplicationMembersRepository {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = ?
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
SELECT id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM application_members WHERE application_id = ? AND user_id = ?",
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let member = sqlx::query_as::<_, ApplicationMember>(
|
||||
r#"
|
||||
INSERT INTO application_members (id, application_id, user_id, role, enabled)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
RETURNING id, application_id, user_id, role, enabled, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.bind(role)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET role = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(role)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE application_members
|
||||
SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let app_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM applications WHERE id = ?")
|
||||
.bind(application_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let app_tenant_id = match app_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
let user_tenant: Option<(String,)> =
|
||||
sqlx::query_as("SELECT tenant_id FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let user_tenant_id = match user_tenant {
|
||||
Some(t) => t.0,
|
||||
None => return Err(sqlx::Error::RowNotFound),
|
||||
};
|
||||
|
||||
if app_tenant_id != user_tenant_id {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"user and application must belong to the same tenant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM application_members
|
||||
WHERE application_id = ? AND user_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(application_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,100 @@
|
||||
use crate::db::models::Application;
|
||||
use crate::db::repository::sqlite::global_slugs::{
|
||||
release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite,
|
||||
};
|
||||
use crate::db::repository::traits::ApplicationsRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Application;
|
||||
|
||||
pub struct SqliteApplicationsRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
async fn create(
|
||||
async fn create_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
client_id: &str,
|
||||
client_secret_hash: Option<&str>,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<Application, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>(
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, slug, "application", id, tenant_id).await?;
|
||||
|
||||
let app = sqlx::query_as::<_, Application>(
|
||||
r#"
|
||||
INSERT INTO applications (id, tenant_id, name, slug)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris
|
||||
INSERT INTO applications (id, tenant_id, name, slug, client_id, client_secret_hash, description, redirect_uris, scopes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id, tenant_id, name, slug, client_id, description, enabled, client_secret_hash, redirect_uris, scopes, created_at, updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(tenant_id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.bind(client_id)
|
||||
.bind(client_secret_hash)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE slug = ?")
|
||||
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, client_id, description, enabled, client_secret_hash, redirect_uris, scopes, created_at, updated_at FROM applications WHERE slug = ?")
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_by_client_id(&self, client_id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, client_id, description, enabled, client_secret_hash, redirect_uris, scopes, created_at, updated_at FROM applications WHERE client_id = ?")
|
||||
.bind(client_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE id = ?")
|
||||
sqlx::query_as::<_, Application>("SELECT id, tenant_id, name, slug, client_id, description, enabled, client_secret_hash, redirect_uris, scopes, created_at, updated_at FROM applications WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -48,7 +102,7 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
|
||||
async fn list(&self, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Application>(
|
||||
"SELECT id, tenant_id, name, slug, enabled, created_at, updated_at, NULL as description, NULL as client_secret_hash, NULL as redirect_uris FROM applications WHERE tenant_id = ? ORDER BY name",
|
||||
"SELECT id, tenant_id, name, slug, client_id, description, enabled, client_secret_hash, redirect_uris, scopes, created_at, updated_at FROM applications WHERE tenant_id = ? ORDER BY name",
|
||||
)
|
||||
.bind(tenant_id)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -57,12 +111,74 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
|
||||
async fn set_enabled(&self, 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(&self.pool)
|
||||
.await?;
|
||||
"UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_secret_hash(&self, id: &str, secret_hash: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE applications SET client_secret_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(secret_hash)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn rotate_secret_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
secret_hash: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let res = sqlx::query(
|
||||
"UPDATE applications SET client_secret_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||
)
|
||||
.bind(secret_hash)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
if let Some(event) = audit_event {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let severity_str = event.severity.to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(event.actor_id)
|
||||
.bind(event.target_id)
|
||||
.bind(event.action)
|
||||
.bind(event.resource_type)
|
||||
.bind(event.resource_id)
|
||||
.bind(&severity_str)
|
||||
.bind(event.ip)
|
||||
.bind(event.ua)
|
||||
.bind(event.metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,30 +187,78 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, slug = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, slug, "application", id, &existing.tenant_id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE applications
|
||||
SET name = ?, slug = ?, description = ?, redirect_uris = ?, scopes = ?, enabled = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(description)
|
||||
.bind(redirect_uris)
|
||||
.bind(scopes)
|
||||
.bind(enabled)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "application", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_sqlite(&mut tx, "application", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM applications WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
use crate::db::models::{AuditFilter, AuditLog};
|
||||
use crate::db::repository::traits::AuditRepository;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::{AuditFilter, AuditLog};
|
||||
|
||||
pub struct SqliteAuditRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuditRepository for SqliteAuditRepository {
|
||||
/// Count all audit log entries.
|
||||
async fn count(&self) -> Result<i64, sqlx::Error> {
|
||||
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM audit_logs")
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn insert(
|
||||
&self,
|
||||
@@ -32,29 +30,13 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
user_agent: Option<&str>,
|
||||
metadata_json: Option<&str>,
|
||||
) -> Result<AuditLog, sqlx::Error> {
|
||||
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(&self.pool)
|
||||
.await
|
||||
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(&self.pool).await
|
||||
}
|
||||
|
||||
async fn list_recent(&self, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
@@ -65,81 +47,80 @@ impl AuditRepository for SqliteAuditRepository {
|
||||
}
|
||||
|
||||
async fn list_filtered(&self, filter: &AuditFilter) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
// Build a dynamic but simple filter using COALESCE-style optional matches.
|
||||
// Empty optionals are treated as wildcards via OR IS NULL pattern with bind of None.
|
||||
let search_like = filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||
|
||||
sqlx::query_as::<_, AuditLog>(
|
||||
r#"
|
||||
SELECT * FROM audit_logs
|
||||
WHERE (?1 IS NULL OR actor_user_id = ?1)
|
||||
AND (?2 IS NULL OR action = ?2)
|
||||
AND (?3 IS NULL OR resource_type = ?3)
|
||||
AND (?4 IS NULL OR severity = ?4)
|
||||
AND (?5 IS NULL OR created_at >= ?5)
|
||||
AND (?6 IS NULL OR created_at <= ?6)
|
||||
AND (
|
||||
?7 IS NULL
|
||||
OR action LIKE ?7 ESCAPE '\'
|
||||
OR resource_type LIKE ?7 ESCAPE '\'
|
||||
OR resource_id LIKE ?7 ESCAPE '\'
|
||||
OR ip_address LIKE ?7 ESCAPE '\'
|
||||
OR metadata_json LIKE ?7 ESCAPE '\'
|
||||
)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?8 OFFSET ?9
|
||||
"#,
|
||||
)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
list_filtered(&self.pool, filter).await
|
||||
}
|
||||
|
||||
async fn count_filtered(&self, filter: &AuditFilter) -> Result<i64, sqlx::Error> {
|
||||
let search_like = filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")));
|
||||
let search_like = search_like(filter);
|
||||
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||
let row: (i64,) = sqlx::query_as(FILTER_COUNT_SQL)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.resource_id.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.bind(success_val)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
}
|
||||
|
||||
let row: (i64,) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT COUNT(*) FROM audit_logs
|
||||
WHERE (?1 IS NULL OR actor_user_id = ?1)
|
||||
AND (?2 IS NULL OR action = ?2)
|
||||
AND (?3 IS NULL OR resource_type = ?3)
|
||||
AND (?4 IS NULL OR severity = ?4)
|
||||
AND (?5 IS NULL OR created_at >= ?5)
|
||||
AND (?6 IS NULL OR created_at <= ?6)
|
||||
AND (
|
||||
?7 IS NULL
|
||||
OR action LIKE ?7 ESCAPE '\'
|
||||
OR resource_type LIKE ?7 ESCAPE '\'
|
||||
OR resource_id LIKE ?7 ESCAPE '\'
|
||||
OR ip_address LIKE ?7 ESCAPE '\'
|
||||
OR metadata_json LIKE ?7 ESCAPE '\'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
fn search_like(filter: &AuditFilter) -> Option<String> {
|
||||
filter
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| format!("%{}%", s.replace('%', "\\%")))
|
||||
}
|
||||
|
||||
async fn list_filtered(
|
||||
pool: &SqlitePool,
|
||||
filter: &AuditFilter,
|
||||
) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||
let search_like = search_like(filter);
|
||||
let success_val = filter.success.map(|b| if b { 1i32 } else { 0i32 });
|
||||
sqlx::query_as::<_, AuditLog>(FILTER_LIST_SQL)
|
||||
.bind(filter.actor_user_id.as_deref())
|
||||
.bind(filter.action.as_deref())
|
||||
.bind(filter.resource_type.as_deref())
|
||||
.bind(filter.resource_id.as_deref())
|
||||
.bind(filter.severity.as_deref())
|
||||
.bind(filter.since.as_deref())
|
||||
.bind(filter.until.as_deref())
|
||||
.bind(search_like.as_deref())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
.bind(success_val)
|
||||
.bind(filter.limit)
|
||||
.bind(filter.offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
const FILTER_LIST_SQL: &str = r#"
|
||||
SELECT * FROM audit_logs
|
||||
WHERE (?1 IS NULL OR actor_user_id = ?1)
|
||||
AND (?2 IS NULL OR action = ?2)
|
||||
AND (?3 IS NULL OR resource_type = ?3)
|
||||
AND (?4 IS NULL OR resource_id = ?4)
|
||||
AND (?5 IS NULL OR severity = ?5)
|
||||
AND (?6 IS NULL OR created_at >= ?6)
|
||||
AND (?7 IS NULL OR created_at <= ?7)
|
||||
AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\')
|
||||
AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
|
||||
ORDER BY created_at DESC LIMIT ?10 OFFSET ?11
|
||||
"#;
|
||||
|
||||
const FILTER_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*) FROM audit_logs
|
||||
WHERE (?1 IS NULL OR actor_user_id = ?1)
|
||||
AND (?2 IS NULL OR action = ?2)
|
||||
AND (?3 IS NULL OR resource_type = ?3)
|
||||
AND (?4 IS NULL OR resource_id = ?4)
|
||||
AND (?5 IS NULL OR severity = ?5)
|
||||
AND (?6 IS NULL OR created_at >= ?6)
|
||||
AND (?7 IS NULL OR created_at <= ?7)
|
||||
AND (?8 IS NULL OR action LIKE ?8 ESCAPE '\' OR resource_type LIKE ?8 ESCAPE '\' OR resource_id LIKE ?8 ESCAPE '\' OR ip_address LIKE ?8 ESCAPE '\' OR metadata_json LIKE ?8 ESCAPE '\')
|
||||
AND (?9 IS NULL OR (?9 = 1 AND action NOT LIKE '%fail%' AND action NOT LIKE '%denied%' AND severity != 'critical') OR (?9 = 0 AND (action LIKE '%fail%' OR action LIKE '%denied%' OR severity = 'critical')))
|
||||
"#;
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::db::models::GlobalSlug;
|
||||
use crate::db::repository::traits::GlobalSlugsRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteGlobalSlugsRepository {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl GlobalSlugsRepository for SqliteGlobalSlugsRepository {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error> {
|
||||
sqlx::query_as::<_, GlobalSlug>(
|
||||
"SELECT slug, entity_type, entity_id, tenant_id, created_at FROM global_slugs WHERE slug = ?"
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reserve_slug_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
tenant_id: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO global_slugs (slug, entity_type, entity_id, tenant_id) VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.bind(tenant_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_slug_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query("DELETE FROM global_slugs WHERE entity_type = ? AND entity_id = ?")
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn release_slug_by_name_sqlite(
|
||||
conn: &mut sqlx::SqliteConnection,
|
||||
slug: &str,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM global_slugs WHERE slug = ? AND entity_type = ? AND entity_id = ?",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(entity_type)
|
||||
.bind(entity_id)
|
||||
.execute(conn)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod audit;
|
||||
pub mod global_slugs;
|
||||
pub mod groups;
|
||||
pub mod permissions;
|
||||
pub mod refresh_tokens;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::models::Tenant;
|
||||
use crate::db::repository::sqlite::global_slugs::{
|
||||
release_slug_by_name_sqlite, release_slug_sqlite, reserve_slug_sqlite,
|
||||
};
|
||||
use crate::db::repository::traits::TenantsRepository;
|
||||
use crate::identity::slug::{slugify, validate_slug};
|
||||
|
||||
pub struct SqliteTenantsRepository {
|
||||
pub pool: SqlitePool,
|
||||
@@ -47,7 +51,17 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
name: &str,
|
||||
slug: Option<&str>,
|
||||
) -> Result<Tenant, sqlx::Error> {
|
||||
let slug = slug.unwrap_or(id);
|
||||
let final_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => slugify(name).map_err(|e| sqlx::Error::Protocol(e.to_string()))?,
|
||||
};
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let row = sqlx::query_as::<_, Tenant>(
|
||||
r#"
|
||||
INSERT INTO tenants (id, name, slug, enabled)
|
||||
@@ -57,27 +71,68 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.fetch_one(&self.pool)
|
||||
.bind(&final_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, &final_slug, "tenant", id, id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn update(&self, id: &str, name: &str, slug: Option<&str>) -> Result<(), sqlx::Error> {
|
||||
let slug = slug.unwrap_or(name);
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(slug)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let existing = self.find_by_id(id).await?.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
let target_slug = match slug {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let trimmed = s.trim();
|
||||
validate_slug(trimmed).map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
|
||||
trimmed.to_string()
|
||||
}
|
||||
_ => existing.slug.clone().unwrap_or_else(|| id.to_string()),
|
||||
};
|
||||
|
||||
let existing_slug_str = existing.slug.as_deref().unwrap_or("");
|
||||
|
||||
if target_slug == existing_slug_str {
|
||||
// Unchanged slug: registry no-op
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
} else {
|
||||
// Changed slug: single transaction reserve -> update -> release
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
reserve_slug_sqlite(&mut tx, &target_slug, "tenant", id, id).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE tenants
|
||||
SET name = ?, slug = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&target_slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !existing_slug_str.is_empty() {
|
||||
release_slug_by_name_sqlite(&mut tx, existing_slug_str, "tenant", id).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -99,10 +154,16 @@ impl TenantsRepository for SqliteTenantsRepository {
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
release_slug_sqlite(&mut tx, "tenant", id).await?;
|
||||
|
||||
sqlx::query("DELETE FROM tenants WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,123 @@ impl UsersRepository for SqliteUsersRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(sqlx::Error::RowNotFound)?;
|
||||
|
||||
if user.tenant_id == destination_tenant_id {
|
||||
tx.commit().await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let from_tenant_id = user.tenant_id.clone();
|
||||
|
||||
if from_tenant_id == crate::db::models::Tenant::DEFAULT_ID {
|
||||
let is_admin: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ? AND r.name = 'admin'",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if is_admin.is_some() {
|
||||
let admin_count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(DISTINCT ur.user_id) FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE r.name = 'admin'",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if admin_count.0 <= 1 {
|
||||
return Err(sqlx::Error::Protocol(
|
||||
"cannot reassign the last system administrator away from default tenant"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dest_exists: Option<(String,)> = sqlx::query_as("SELECT id FROM tenants WHERE id = ?")
|
||||
.bind(destination_tenant_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if dest_exists.is_none() {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let collision: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM users WHERE tenant_id = ? AND username = ?")
|
||||
.bind(destination_tenant_id)
|
||||
.bind(&user.username)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if collision.is_some() {
|
||||
return Err(sqlx::Error::Protocol(format!(
|
||||
"username '{}' already exists in target tenant",
|
||||
user.username
|
||||
)));
|
||||
}
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE users SET tenant_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ? AND tenant_id = ?",
|
||||
)
|
||||
.bind(destination_tenant_id)
|
||||
.bind(user_id)
|
||||
.bind(&from_tenant_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
}
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": destination_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&audit_id)
|
||||
.bind(actor_id)
|
||||
.bind(Some(&user.id))
|
||||
.bind("user.tenant_reassigned")
|
||||
.bind("user")
|
||||
.bind(Some(&user.id))
|
||||
.bind("info")
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(&metadata)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_password_hash(&self, 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 = ?",
|
||||
|
||||
+101
-3
@@ -1,8 +1,13 @@
|
||||
use crate::db::models::{
|
||||
ApiToken, Application, AuditFilter, AuditLog, Group, Permission, RefreshToken, Role,
|
||||
ServiceAccount, Session, Tenant, User, UserProfile,
|
||||
ApiToken, Application, ApplicationMember, AuditFilter, AuditLog, GlobalSlug, Group, Permission,
|
||||
RefreshToken, Role, ServiceAccount, Session, Tenant, User, UserProfile,
|
||||
};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait GlobalSlugsRepository: Send + Sync {
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<GlobalSlug>, sqlx::Error>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait UsersRepository: Send + Sync {
|
||||
async fn find_by_id(&self, id: &str) -> Result<Option<User>, sqlx::Error>;
|
||||
@@ -16,6 +21,14 @@ pub trait UsersRepository: Send + Sync {
|
||||
password_hash: &str,
|
||||
) -> Result<User, sqlx::Error>;
|
||||
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error>;
|
||||
async fn reassign_user_tenant_with_audit(
|
||||
&self,
|
||||
user_id: &str,
|
||||
destination_tenant_id: &str,
|
||||
actor_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error>;
|
||||
async fn set_last_login(&self, id: &str) -> Result<(), sqlx::Error>;
|
||||
async fn username_exists(&self, tenant_id: &str, username: &str) -> Result<bool, sqlx::Error>;
|
||||
@@ -96,22 +109,41 @@ pub trait SessionsRepository: Send + Sync {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ApplicationsRepository: Send + Sync {
|
||||
async fn create(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
client_id: &str,
|
||||
client_secret_hash: Option<&str>,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<Application, sqlx::Error>;
|
||||
async fn find_by_slug(&self, slug: &str) -> Result<Option<Application>, sqlx::Error>;
|
||||
async fn find_by_client_id(&self, client_id: &str) -> Result<Option<Application>, sqlx::Error>;
|
||||
async fn find_by_id(&self, id: &str) -> Result<Option<Application>, sqlx::Error>;
|
||||
async fn list(&self, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error>;
|
||||
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error>;
|
||||
async fn update_secret_hash(&self, id: &str, secret_hash: &str) -> Result<(), sqlx::Error>;
|
||||
async fn rotate_secret_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
secret_hash: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update(
|
||||
&self,
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<&str>,
|
||||
scopes: Option<&str>,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn delete(&self, id: &str) -> Result<(), sqlx::Error>;
|
||||
@@ -252,3 +284,69 @@ pub trait GroupsRepository: Send + Sync {
|
||||
async fn remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
|
||||
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error>;
|
||||
}
|
||||
|
||||
/// Application membership repository (user ↔ application assignment).
|
||||
///
|
||||
/// Membership roles are lightweight metadata and do not modify global RBAC.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ApplicationMembersRepository: Send + Sync {
|
||||
async fn list_by_application(
|
||||
&self,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, sqlx::Error>;
|
||||
async fn list_by_user(&self, user_id: &str) -> Result<Vec<ApplicationMember>, sqlx::Error>;
|
||||
async fn find(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, sqlx::Error>;
|
||||
async fn add(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<ApplicationMember, sqlx::Error>;
|
||||
async fn update_role(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn remove(&self, application_id: &str, user_id: &str) -> Result<(), sqlx::Error>;
|
||||
|
||||
async fn add_with_audit(
|
||||
&self,
|
||||
id: &str,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<ApplicationMember, sqlx::Error>;
|
||||
async fn update_role_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn set_enabled_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
enabled: bool,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
async fn remove_with_audit(
|
||||
&self,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_event: Option<crate::audit::AuditEvent<'_>>,
|
||||
) -> Result<(), sqlx::Error>;
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! Application membership domain logic.
|
||||
//!
|
||||
//! Assigns existing NX9-Auth users to registered applications.
|
||||
//! Membership roles (owner/admin/member) are lightweight metadata only and
|
||||
//! MUST NOT grant global RBAC permissions such as `applications:manage`.
|
||||
|
||||
use crate::db::models::{Application, ApplicationMember, ApplicationMembershipRole};
|
||||
use crate::error::AppError;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn parse_role(role: Option<&str>) -> Result<ApplicationMembershipRole, AppError> {
|
||||
match role {
|
||||
None | Some("") => Ok(ApplicationMembershipRole::Member),
|
||||
Some(r) => ApplicationMembershipRole::parse(r).ok_or_else(|| {
|
||||
AppError::InvalidInput(format!(
|
||||
"invalid membership role '{r}'; allowed values are owner, admin, member"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// List members of an application.
|
||||
pub async fn list_by_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_application(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// List application memberships for a user.
|
||||
pub async fn list_by_user(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<ApplicationMember>, AppError> {
|
||||
let _ = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.list_by_user(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Find a single membership.
|
||||
pub async fn find(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<Option<ApplicationMember>, AppError> {
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// Assign an existing same-tenant user to an application.
|
||||
pub async fn add(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
let role = parse_role(role)?;
|
||||
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let user = provider
|
||||
.users()
|
||||
.find_by_id(user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Tenant isolation: never allow cross-tenant assignment.
|
||||
if user.tenant_id != app.tenant_id {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
if provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"user is already a member of this application".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_added",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
let member = provider
|
||||
.application_members()
|
||||
.add_with_audit(
|
||||
&id,
|
||||
application_id,
|
||||
user_id,
|
||||
role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let _ = app;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
/// Update membership role and/or enabled state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
role: Option<&str>,
|
||||
enabled: Option<bool>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<ApplicationMember, AppError> {
|
||||
if role.is_none() && enabled.is_none() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"at least one of role or enabled must be provided".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
if let Some(role_str) = role {
|
||||
let new_role = parse_role(Some(role_str))?;
|
||||
if new_role.as_str() != existing.role {
|
||||
let previous_role = existing.role.clone();
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"previous_role": previous_role,
|
||||
"new_role": new_role.as_str(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_role_changed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.update_role_with_audit(
|
||||
application_id,
|
||||
user_id,
|
||||
new_role.as_str(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_enabled) = enabled
|
||||
&& new_enabled != existing.enabled
|
||||
{
|
||||
let action = if new_enabled {
|
||||
"application.member_enabled"
|
||||
} else {
|
||||
"application.member_disabled"
|
||||
};
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
"enabled": new_enabled,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action,
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.set_enabled_with_audit(application_id, user_id, new_enabled, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
/// Remove a user from an application (does not delete the user account).
|
||||
pub async fn remove(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
user_id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
// Ensure application exists
|
||||
let _ = provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let existing = provider
|
||||
.application_members()
|
||||
.find(application_id, user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": application_id,
|
||||
"user_id": user_id,
|
||||
"role": existing.role,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: Some(user_id),
|
||||
action: "application.member_removed",
|
||||
resource_type: "application",
|
||||
resource_id: Some(application_id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.application_members()
|
||||
.remove_with_audit(application_id, user_id, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper used by API responses that need application details for a membership.
|
||||
pub async fn load_application(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
application_id: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
provider
|
||||
.applications()
|
||||
.find_by_id(application_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
+377
-18
@@ -1,11 +1,106 @@
|
||||
use crate::db::repository::traits::AuditRepositoryExt;
|
||||
use crate::{db::models::Application, error::AppError};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
pub const CLIENT_ID_PREFIX: &str = "nx9_app_";
|
||||
pub const CLIENT_SECRET_PREFIX: &str = "nx9_secret_";
|
||||
|
||||
/// Generate a new unique server-side Client ID.
|
||||
pub fn generate_client_id() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
|
||||
format!("{}{}", CLIENT_ID_PREFIX, hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Generate a new CSPRNG Client Secret.
|
||||
pub fn generate_client_secret() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
|
||||
format!("{}{}", CLIENT_SECRET_PREFIX, hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Hash a raw client secret string into a hex-encoded BLAKE3 digest.
|
||||
pub fn hash_client_secret(raw: &str) -> String {
|
||||
hex::encode(blake3::hash(raw.as_bytes()).as_bytes())
|
||||
}
|
||||
|
||||
/// Hash a raw client secret string into a 32-byte BLAKE3 digest.
|
||||
pub fn hash_secret_bytes(raw: &str) -> [u8; 32] {
|
||||
*blake3::hash(raw.as_bytes()).as_bytes()
|
||||
}
|
||||
|
||||
/// Constant-time byte array comparison using subtle::ConstantTimeEq.
|
||||
pub fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.ct_eq(b).into()
|
||||
}
|
||||
|
||||
pub fn validate_redirect_uris(uris: &[String]) -> Result<(), AppError> {
|
||||
if uris.len() > 10 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"maximum 10 redirect URIs allowed".into(),
|
||||
));
|
||||
}
|
||||
for uri in uris {
|
||||
let trimmed = uri.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"redirect URI cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if trimmed.len() > 512 {
|
||||
return Err(AppError::InvalidInput(
|
||||
"redirect URI exceeds maximum length of 512 characters".into(),
|
||||
));
|
||||
}
|
||||
let parsed = url::Url::parse(trimmed).map_err(|e| {
|
||||
AppError::InvalidInput(format!("invalid redirect URI '{trimmed}': {e}"))
|
||||
})?;
|
||||
if parsed.fragment().is_some() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"redirect URI '{trimmed}' must not contain a fragment"
|
||||
)));
|
||||
}
|
||||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"redirect URI '{trimmed}' must not contain user credentials"
|
||||
)));
|
||||
}
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
"http" => {
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
if host != "localhost" && host != "127.0.0.1" && host != "[::1]" && host != "::1" {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"redirect URI '{trimmed}' with http scheme is only allowed for localhost/loopback development"
|
||||
)));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"redirect URI '{trimmed}' has unsupported scheme '{other}'; only https (or http for localhost) is allowed"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
tenant_id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<Vec<String>>,
|
||||
scopes: Option<Vec<String>>,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(Application, String), AppError> {
|
||||
let name = name.trim();
|
||||
let slug = slug.trim();
|
||||
if name.is_empty() || slug.is_empty() {
|
||||
@@ -13,8 +108,13 @@ pub async fn create(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
@@ -22,12 +122,52 @@ pub async fn create(
|
||||
{
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
let client_id = generate_client_id();
|
||||
let raw_secret = generate_client_secret();
|
||||
let secret_hash = hash_client_secret(&raw_secret);
|
||||
|
||||
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||
let scopes_json = scopes.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": id,
|
||||
"name": name,
|
||||
"client_id": client_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "application.created",
|
||||
resource_type: "application",
|
||||
resource_id: Some(&id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
let app = provider
|
||||
.applications()
|
||||
.create(&id, tenant_id, name, slug)
|
||||
.create_with_audit(
|
||||
&id,
|
||||
tenant_id,
|
||||
name,
|
||||
slug,
|
||||
&client_id,
|
||||
Some(&secret_hash),
|
||||
description,
|
||||
redirect_json.as_deref(),
|
||||
scopes_json.as_deref(),
|
||||
Some(audit_event),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok((app, raw_secret))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
@@ -65,14 +205,108 @@ pub async fn find_by_slug(
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
|
||||
pub async fn rotate_secret(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let raw_secret = generate_client_secret();
|
||||
let secret_hash = hash_client_secret(&raw_secret);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": id,
|
||||
"name": app.name,
|
||||
"client_id": app.get_client_id(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let audit_event = crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "application.secret_rotated",
|
||||
resource_type: "application",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
};
|
||||
|
||||
provider
|
||||
.applications()
|
||||
.rotate_secret_with_audit(id, &secret_hash, Some(audit_event))
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(raw_secret)
|
||||
}
|
||||
|
||||
pub async fn validate_client_credentials(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<Application, AppError> {
|
||||
let supplied_digest = hash_secret_bytes(client_secret);
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_client_id(client_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let dummy_digest = [0u8; 32];
|
||||
|
||||
let (valid_app, stored_digest_opt) = match app {
|
||||
Some(ref a) if a.enabled => {
|
||||
let digest_opt = a
|
||||
.client_secret_hash
|
||||
.as_ref()
|
||||
.and_then(|h| hex::decode(h).ok())
|
||||
.and_then(|vec| <[u8; 32]>::try_from(vec).ok());
|
||||
(digest_opt.is_some(), digest_opt)
|
||||
}
|
||||
_ => (false, None),
|
||||
};
|
||||
|
||||
let target_digest = stored_digest_opt.as_ref().unwrap_or(&dummy_digest);
|
||||
let matches = constant_time_compare(&supplied_digest, target_digest);
|
||||
|
||||
if valid_app && matches {
|
||||
Ok(app.unwrap())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
description: Option<&str>,
|
||||
redirect_uris: Option<Vec<String>>,
|
||||
scopes: Option<Vec<String>>,
|
||||
enabled: bool,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<Application, AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
let existing = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let name = name.trim();
|
||||
let slug = slug.trim();
|
||||
if name.is_empty() || slug.is_empty() {
|
||||
@@ -80,50 +314,175 @@ pub async fn update(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
crate::identity::slug::validate_slug(slug)?;
|
||||
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if let Some(other) = provider
|
||||
.applications()
|
||||
.global_slugs()
|
||||
.find_by_slug(slug)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
&& (other.entity_id != id || other.entity_type != "application")
|
||||
{
|
||||
if other.id != id {
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
|
||||
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||
let scopes_json = scopes.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
||||
|
||||
provider
|
||||
.applications()
|
||||
.update(id, name, slug, enabled)
|
||||
.update(
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
redirect_json.as_deref(),
|
||||
scopes_json.as_deref(),
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
provider
|
||||
|
||||
let updated = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Database)?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound)
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let action = if existing.enabled != enabled {
|
||||
if enabled {
|
||||
"application.enabled"
|
||||
} else {
|
||||
"application.disabled"
|
||||
}
|
||||
} else {
|
||||
"application.updated"
|
||||
};
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": id,
|
||||
"name": updated.name,
|
||||
"client_id": updated.get_client_id(),
|
||||
"enabled": enabled,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action,
|
||||
resource_type: "application",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn set_enabled(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
enabled: bool,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.applications()
|
||||
.set_enabled(id, enabled)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let action = if enabled {
|
||||
"application.enabled"
|
||||
} else {
|
||||
"application.disabled"
|
||||
};
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": id,
|
||||
"name": app.name,
|
||||
"client_id": app.get_client_id(),
|
||||
"enabled": enabled,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action,
|
||||
resource_type: "application",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Info,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(
|
||||
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
||||
id: &str,
|
||||
audit_actor_id: Option<&str>,
|
||||
audit_ip: Option<&str>,
|
||||
audit_ua: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let _ = provider.applications().find_by_id(id).await?;
|
||||
let app = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
provider
|
||||
.applications()
|
||||
.delete(id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"application_id": id,
|
||||
"name": app.name,
|
||||
"client_id": app.get_client_id(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.log(crate::audit::AuditEvent {
|
||||
actor_id: audit_actor_id,
|
||||
target_id: None,
|
||||
action: "application.deleted",
|
||||
resource_type: "application",
|
||||
resource_id: Some(id),
|
||||
severity: crate::db::models::AuditSeverity::Warning,
|
||||
ip: audit_ip,
|
||||
ua: audit_ua,
|
||||
metadata: Some(&metadata),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod application_members;
|
||||
pub mod applications;
|
||||
pub mod permissions;
|
||||
pub mod roles;
|
||||
pub mod service_accounts;
|
||||
pub mod slug;
|
||||
pub mod users;
|
||||
@@ -191,10 +191,9 @@ pub async fn update_role(
|
||||
.find_by_name(name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
&& other.id != id
|
||||
{
|
||||
if other.id != id {
|
||||
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
||||
}
|
||||
return Err(AppError::Conflict(format!("role '{name}' already exists")));
|
||||
}
|
||||
|
||||
provider
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use crate::error::AppError;
|
||||
|
||||
pub const RESERVED_SLUGS: &[&str] = &[
|
||||
"admin",
|
||||
"api",
|
||||
"system",
|
||||
"auth",
|
||||
"login",
|
||||
"logout",
|
||||
"dashboard",
|
||||
"health",
|
||||
"metrics",
|
||||
"root",
|
||||
"public",
|
||||
"private",
|
||||
"null",
|
||||
"undefined",
|
||||
"config",
|
||||
"settings",
|
||||
"account",
|
||||
"accounts",
|
||||
"role",
|
||||
"roles",
|
||||
"permission",
|
||||
"permissions",
|
||||
"group",
|
||||
"groups",
|
||||
"service-account",
|
||||
"service-accounts",
|
||||
];
|
||||
|
||||
/// Validates an explicit or derived slug string according to server-side policy:
|
||||
/// - Must be 2..=63 characters in length.
|
||||
/// - Must consist only of lowercase ASCII alphanumeric characters ('a'..='z', '0'..='9') and hyphens ('-').
|
||||
/// - Cannot start or end with a hyphen.
|
||||
/// - Cannot contain consecutive hyphens ("--").
|
||||
/// - Cannot be one of the reserved slug names (except "default" which is preserved for built-in tenant).
|
||||
pub fn validate_slug(slug: &str) -> Result<(), AppError> {
|
||||
let s = slug.trim();
|
||||
if s.is_empty() {
|
||||
return Err(AppError::InvalidInput("slug cannot be empty".into()));
|
||||
}
|
||||
if s.len() < 2 || s.len() > 63 {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug length must be between 2 and 63 characters, got {}",
|
||||
s.len()
|
||||
)));
|
||||
}
|
||||
if s.starts_with('-') || s.ends_with('-') {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot start or end with a hyphen".into(),
|
||||
));
|
||||
}
|
||||
if s.contains("--") {
|
||||
return Err(AppError::InvalidInput(
|
||||
"slug cannot contain consecutive hyphens".into(),
|
||||
));
|
||||
}
|
||||
for ch in s.chars() {
|
||||
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '-' {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug contains invalid character '{ch}'; only lowercase alphanumeric characters and hyphens are allowed"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&s) {
|
||||
return Err(AppError::InvalidInput(format!(
|
||||
"slug '{s}' is reserved by system"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slugifies a display name when CREATE omits an explicit slug.
|
||||
/// Converts non-alphanumeric characters to hyphens, lowercases the string,
|
||||
/// collapses repeated hyphens, and validates the result.
|
||||
pub fn slugify(input: &str) -> Result<String, AppError> {
|
||||
let mut slug = String::with_capacity(input.len());
|
||||
let mut prev_hyphen = false;
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
prev_hyphen = false;
|
||||
} else if !prev_hyphen && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
prev_hyphen = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = slug.trim_matches('-');
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::InvalidInput(
|
||||
"unable to generate valid slug from provided name".into(),
|
||||
));
|
||||
}
|
||||
|
||||
validate_slug(trimmed)?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_slugs() {
|
||||
assert!(validate_slug("default").is_ok());
|
||||
assert!(validate_slug("my-app-1").is_ok());
|
||||
assert!(validate_slug("acme-corp").is_ok());
|
||||
assert!(validate_slug("xy").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_slugs() {
|
||||
assert!(validate_slug("").is_err());
|
||||
assert!(validate_slug("a").is_err());
|
||||
assert!(validate_slug("-app").is_err());
|
||||
assert!(validate_slug("app-").is_err());
|
||||
assert!(validate_slug("my--app").is_err());
|
||||
assert!(validate_slug("My-App").is_err());
|
||||
assert!(validate_slug("my_app").is_err());
|
||||
assert!(validate_slug("admin").is_err());
|
||||
assert!(validate_slug("api").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_slugify() {
|
||||
assert_eq!(slugify("Acme Corp!").unwrap(), "acme-corp");
|
||||
assert_eq!(slugify("My App 123").unwrap(), "my-app-123");
|
||||
assert!(slugify("!!!").is_err());
|
||||
}
|
||||
}
|
||||
+46
-50
@@ -72,59 +72,55 @@ where
|
||||
// 2. Try Authorization: Bearer — PAT first, then session token.
|
||||
// Session tokens are returned from /auth/login for SPA clients that
|
||||
// cannot rely solely on the HttpOnly cookie.
|
||||
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 ") {
|
||||
let raw = raw.trim();
|
||||
if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION)
|
||||
&& let Ok(value) = auth_header.to_str()
|
||||
&& let Some(raw) = value.strip_prefix("Bearer ")
|
||||
{
|
||||
let raw = raw.trim();
|
||||
|
||||
// 2a. Personal access token
|
||||
if let Some(token) = tokens::validate_token(&app_state.provider, raw).await? {
|
||||
let user = app_state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&token.user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
// 2a. Personal access token
|
||||
if let Some(token) = tokens::validate_token(&app_state.provider, raw).await? {
|
||||
let user = app_state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&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,
|
||||
});
|
||||
}
|
||||
|
||||
// 2b. Session token (same value as nx9_session cookie)
|
||||
if let Some(session) = sessions::validate_session(
|
||||
&app_state.provider,
|
||||
raw,
|
||||
&app_state.config.security,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let user = app_state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&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),
|
||||
});
|
||||
}
|
||||
if !user.is_active() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
return Ok(AuthUser {
|
||||
user,
|
||||
method: AuthMethod::Token,
|
||||
session_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 2b. Session token (same value as nx9_session cookie)
|
||||
if let Some(session) =
|
||||
sessions::validate_session(&app_state.provider, raw, &app_state.config.security)
|
||||
.await?
|
||||
{
|
||||
let user = app_state
|
||||
.provider
|
||||
.users()
|
||||
.find_by_id(&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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
-61
@@ -29,6 +29,8 @@ pub struct Application {
|
||||
pub signals: SignalManager,
|
||||
pub shutdown: ShutdownCoordinator,
|
||||
pub metrics: RuntimeMetrics,
|
||||
pub local_addr: Option<std::net::SocketAddr>,
|
||||
pub bound_port: Arc<std::sync::atomic::AtomicU16>,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
@@ -82,52 +84,57 @@ impl Application {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Force a runtime state update.
|
||||
/// Force advance runtime state (monotonic, forward-only).
|
||||
pub fn set_state(&self, state: RuntimeState) {
|
||||
self.state.force_set(state);
|
||||
self.state.force_advance(state);
|
||||
}
|
||||
|
||||
/// Perform graceful shutdown flow explicitly.
|
||||
pub async fn perform_shutdown(&mut self) -> Result<()> {
|
||||
if !self.state.initiate_shutdown() {
|
||||
if self.state.load().is_shutting_down() {
|
||||
return Ok(());
|
||||
let current_state = self.state.load();
|
||||
|
||||
if current_state == RuntimeState::Running {
|
||||
let _ = self.state.initiate_shutdown();
|
||||
} else if !current_state.is_shutting_down() {
|
||||
self.state.force_advance(RuntimeState::Draining);
|
||||
}
|
||||
|
||||
if self.state.load() == RuntimeState::Draining {
|
||||
tracing::info!("draining active connections");
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::Draining, RuntimeState::StoppingWorkers);
|
||||
}
|
||||
|
||||
if self.state.load() == RuntimeState::StoppingWorkers {
|
||||
tracing::info!("stopping background workers");
|
||||
self.workers
|
||||
.shutdown_all_with_coordinator(Duration::from_secs(10), Some(&self.shutdown))
|
||||
.await;
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::StoppingWorkers, RuntimeState::ExecutingHooks);
|
||||
}
|
||||
|
||||
if self.state.load() == RuntimeState::ExecutingHooks {
|
||||
tracing::info!("executing shutdown hooks");
|
||||
self.hooks.execute_all().await;
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::ExecutingHooks, RuntimeState::ClosingResources);
|
||||
}
|
||||
|
||||
if self.state.load() == RuntimeState::ClosingResources {
|
||||
tracing::info!("closing database connection pool and resources");
|
||||
if let Some(pool) = self.pool_handle.take() {
|
||||
pool.close().await;
|
||||
}
|
||||
self.state.force_set(RuntimeState::Draining);
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::ClosingResources, RuntimeState::Stopped);
|
||||
}
|
||||
|
||||
println!("Draining");
|
||||
tracing::info!("draining active connections");
|
||||
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::Draining, RuntimeState::StoppingWorkers);
|
||||
println!("StoppingWorkers");
|
||||
tracing::info!("stopping background workers");
|
||||
self.workers.shutdown_all(Duration::from_secs(10)).await;
|
||||
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::StoppingWorkers, RuntimeState::ExecutingHooks);
|
||||
println!("ExecutingHooks");
|
||||
tracing::info!("executing shutdown hooks");
|
||||
self.hooks.execute_all().await;
|
||||
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::ExecutingHooks, RuntimeState::ClosingResources);
|
||||
println!("ClosingResources");
|
||||
tracing::info!("closing database connection pool and resources");
|
||||
if let Some(pool) = self.pool_handle.take() {
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::ClosingResources, RuntimeState::Stopped);
|
||||
println!("Stopped");
|
||||
tracing::info!("application stopped cleanly");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -135,12 +142,10 @@ impl Application {
|
||||
#[async_trait::async_trait]
|
||||
impl Lifecycle for Application {
|
||||
async fn initialize(&mut self) -> Result<()> {
|
||||
println!("Initializing");
|
||||
let _ = self
|
||||
.state
|
||||
.transition(RuntimeState::Initializing, RuntimeState::Starting);
|
||||
|
||||
println!("Starting");
|
||||
let config = match &self.config {
|
||||
Some(cfg) => cfg.clone(),
|
||||
None => {
|
||||
@@ -157,12 +162,12 @@ impl Lifecycle for Application {
|
||||
self.pool_handle = Some(pool_handle);
|
||||
}
|
||||
|
||||
if self.router.is_none() {
|
||||
if let Some(provider) = &self.provider {
|
||||
let app_state = crate::state::AppState::new(provider.clone(), config);
|
||||
let router = crate::api::router::build(app_state);
|
||||
self.router = Some(router);
|
||||
}
|
||||
if self.router.is_none()
|
||||
&& let Some(provider) = &self.provider
|
||||
{
|
||||
let app_state = crate::state::AppState::new(provider.clone(), config);
|
||||
let router = crate::api::router::build(app_state);
|
||||
self.router = Some(router);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -179,8 +184,6 @@ impl Lifecycle for Application {
|
||||
.transition(RuntimeState::Starting, RuntimeState::Running);
|
||||
}
|
||||
|
||||
println!("Running");
|
||||
|
||||
let config = self.config.as_ref().cloned().unwrap_or_default();
|
||||
let addr_str = format!("{}:{}", config.server.host, config.server.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr_str)
|
||||
@@ -188,7 +191,9 @@ impl Lifecycle for Application {
|
||||
.with_context(|| format!("failed to bind TCP listener to {addr_str}"))?;
|
||||
|
||||
let local_addr = listener.local_addr()?;
|
||||
println!("Listening on {}", local_addr);
|
||||
self.local_addr = Some(local_addr);
|
||||
self.bound_port
|
||||
.store(local_addr.port(), std::sync::atomic::Ordering::Release);
|
||||
tracing::info!(address = %local_addr, "Listening on {}", local_addr);
|
||||
|
||||
let router = match self.router.take() {
|
||||
@@ -205,24 +210,42 @@ impl Lifecycle for Application {
|
||||
|
||||
let signal_mgr = self.signals.clone();
|
||||
let shutdown_coord = self.shutdown.clone();
|
||||
let state = self.state.clone();
|
||||
|
||||
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
|
||||
tokio::select! {
|
||||
sig = signals::wait_for_shutdown_signal() => {
|
||||
tracing::info!(signal = sig, "received shutdown signal");
|
||||
signal_mgr.record_signal();
|
||||
shutdown_coord.cancel();
|
||||
}
|
||||
_ = shutdown_coord.cancelled() => {
|
||||
tracing::info!("shutdown coordinator cancelled");
|
||||
}
|
||||
}
|
||||
let signal_task = tokio::spawn(signals::listen_for_signals(
|
||||
signal_mgr,
|
||||
shutdown_coord.clone(),
|
||||
self.state.clone(),
|
||||
));
|
||||
|
||||
let graceful_token = shutdown_coord.token().clone();
|
||||
let forced_token = shutdown_coord.forced_token().clone();
|
||||
|
||||
let state_for_shutdown = state.clone();
|
||||
let server_fut = axum::serve(listener, router).with_graceful_shutdown(async move {
|
||||
graceful_token.cancelled().await;
|
||||
tracing::info!("graceful shutdown triggered; initiating HTTP connection draining");
|
||||
let _ = state_for_shutdown.initiate_shutdown();
|
||||
});
|
||||
|
||||
if let Err(err) = server.await {
|
||||
tracing::error!(error = %err, "HTTP server error");
|
||||
let mut server_task = tokio::spawn(async move { server_fut.await });
|
||||
|
||||
tokio::select! {
|
||||
res = &mut server_task => {
|
||||
match res {
|
||||
Ok(Ok(())) => tracing::info!("HTTP server stopped gracefully"),
|
||||
Ok(Err(err)) => tracing::error!(error = %err, "HTTP server error"),
|
||||
Err(join_err) => tracing::debug!(error = %join_err, "HTTP server task finished"),
|
||||
}
|
||||
}
|
||||
_ = forced_token.cancelled() => {
|
||||
tracing::warn!("live forced shutdown escalation received during HTTP drain; aborting server task immediately");
|
||||
server_task.abort();
|
||||
let _ = server_task.await;
|
||||
}
|
||||
}
|
||||
|
||||
signal_task.abort();
|
||||
self.perform_shutdown().await
|
||||
}
|
||||
|
||||
|
||||
@@ -2,36 +2,76 @@
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Dual-token shutdown coordinator that supports graceful termination
|
||||
/// (1st signal) and live forced escalation (2nd signal).
|
||||
#[derive(Clone)]
|
||||
pub struct ShutdownCoordinator {
|
||||
root: CancellationToken,
|
||||
graceful: CancellationToken,
|
||||
forced: CancellationToken,
|
||||
}
|
||||
|
||||
impl ShutdownCoordinator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
root: CancellationToken::new(),
|
||||
graceful: CancellationToken::new(),
|
||||
forced: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the primary graceful cancellation token.
|
||||
pub fn token(&self) -> &CancellationToken {
|
||||
&self.root
|
||||
&self.graceful
|
||||
}
|
||||
|
||||
/// Access the forced cancellation token.
|
||||
pub fn forced_token(&self) -> &CancellationToken {
|
||||
&self.forced
|
||||
}
|
||||
|
||||
/// Create a child token linked to graceful cancellation.
|
||||
pub fn child_token(&self) -> CancellationToken {
|
||||
self.root.child_token()
|
||||
self.graceful.child_token()
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown.
|
||||
pub fn cancel(&self) {
|
||||
self.root.cancel();
|
||||
self.graceful.cancel();
|
||||
}
|
||||
|
||||
/// Trigger graceful shutdown explicitly.
|
||||
pub fn cancel_graceful(&self) {
|
||||
self.graceful.cancel();
|
||||
}
|
||||
|
||||
/// Trigger forced shutdown escalation live.
|
||||
pub fn cancel_forced(&self) {
|
||||
self.graceful.cancel();
|
||||
self.forced.cancel();
|
||||
}
|
||||
|
||||
/// Check if graceful shutdown has been initiated.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.root.is_cancelled()
|
||||
self.graceful.is_cancelled()
|
||||
}
|
||||
|
||||
/// Check if forced escalation has been triggered.
|
||||
pub fn is_forced(&self) -> bool {
|
||||
self.forced.is_cancelled()
|
||||
}
|
||||
|
||||
/// Await graceful cancellation.
|
||||
pub async fn cancelled(&self) {
|
||||
self.root.cancelled().await;
|
||||
self.graceful.cancelled().await;
|
||||
}
|
||||
|
||||
/// Await graceful cancellation explicitly.
|
||||
pub async fn graceful_cancelled(&self) {
|
||||
self.graceful.cancelled().await;
|
||||
}
|
||||
|
||||
/// Await forced escalation live.
|
||||
pub async fn forced_cancelled(&self) {
|
||||
self.forced.cancelled().await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,12 @@ impl HookRegistry {
|
||||
}
|
||||
|
||||
let mut indices: Vec<usize> = (0..self.hooks.len()).collect();
|
||||
indices.sort_by_key(|&i| self.hooks[i].priority());
|
||||
indices.sort_by(
|
||||
|&a, &b| match self.hooks[a].priority().cmp(&self.hooks[b].priority()) {
|
||||
std::cmp::Ordering::Equal => a.cmp(&b),
|
||||
ord => ord,
|
||||
},
|
||||
);
|
||||
|
||||
for i in indices {
|
||||
let hook = &self.hooks[i];
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use super::{AtomicRuntimeState, ShutdownCoordinator};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SignalManager {
|
||||
signal_count: Arc<AtomicUsize>,
|
||||
@@ -32,6 +34,17 @@ impl SignalManager {
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Record a signal and trigger live escalation on the coordinator.
|
||||
pub fn handle_signal(&self, coordinator: &ShutdownCoordinator) -> usize {
|
||||
let count = self.record_signal();
|
||||
if count == 1 {
|
||||
coordinator.cancel_graceful();
|
||||
} else if count >= 2 {
|
||||
coordinator.cancel_forced();
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SignalManager {
|
||||
@@ -65,3 +78,24 @@ pub async fn wait_for_shutdown_signal() -> &'static str {
|
||||
name = sigterm => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Continuous signal monitor that remains active during graceful shutdown
|
||||
/// to observe and trigger forced escalation live.
|
||||
pub async fn listen_for_signals(
|
||||
signal_mgr: SignalManager,
|
||||
coordinator: ShutdownCoordinator,
|
||||
state: AtomicRuntimeState,
|
||||
) {
|
||||
loop {
|
||||
let sig = wait_for_shutdown_signal().await;
|
||||
let count = signal_mgr.handle_signal(&coordinator);
|
||||
tracing::info!(signal = sig, count, "received OS signal");
|
||||
if count == 1 {
|
||||
let _ = state.initiate_shutdown();
|
||||
} else {
|
||||
// 2nd signal received: forced escalation
|
||||
tracing::warn!("second signal received; escalating to forced shutdown");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-13
@@ -70,19 +70,22 @@ impl fmt::Display for RuntimeState {
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Lock-free atomic runtime state container.
|
||||
///
|
||||
/// Uses `AtomicU8` with `compare_exchange` to ensure deterministic,
|
||||
/// race-free state transitions without mutex contention.
|
||||
#[derive(Clone)]
|
||||
pub struct AtomicRuntimeState {
|
||||
state: AtomicU8,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
impl AtomicRuntimeState {
|
||||
/// Create a new state machine in the `Initializing` state.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicU8::new(RuntimeState::Initializing as u8),
|
||||
state: Arc::new(AtomicU8::new(RuntimeState::Initializing as u8)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,15 +94,32 @@ impl AtomicRuntimeState {
|
||||
RuntimeState::from_u8(self.state.load(Ordering::Acquire)).unwrap_or(RuntimeState::Stopped)
|
||||
}
|
||||
|
||||
/// Check if a state transition follows the valid lifecycle graph.
|
||||
pub fn is_valid_transition(expected: RuntimeState, new: RuntimeState) -> bool {
|
||||
(new as u8) == (expected as u8) + 1
|
||||
}
|
||||
|
||||
/// Attempt an atomic state transition from `expected` to `new`.
|
||||
///
|
||||
/// Returns `Ok(new)` if the transition succeeded, or `Err(actual)` if the
|
||||
/// current state did not match `expected`.
|
||||
/// The transition is validated against the lifecycle graph. Returns `Ok(new)`
|
||||
/// if the transition succeeded, or `Err(actual)` if the transition was invalid
|
||||
/// or the current state did not match `expected`.
|
||||
pub fn transition(
|
||||
&self,
|
||||
expected: RuntimeState,
|
||||
new: RuntimeState,
|
||||
) -> Result<RuntimeState, RuntimeState> {
|
||||
if !Self::is_valid_transition(expected, new) {
|
||||
let actual = self.load();
|
||||
tracing::warn!(
|
||||
expected = %expected,
|
||||
actual = %actual,
|
||||
target = %new,
|
||||
"illegal lifecycle graph transition rejected"
|
||||
);
|
||||
return Err(actual);
|
||||
}
|
||||
|
||||
match self.state.compare_exchange(
|
||||
expected as u8,
|
||||
new as u8,
|
||||
@@ -123,13 +143,39 @@ impl AtomicRuntimeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconditionally advance the state. Used during forced shutdown when
|
||||
/// intermediate states may have been skipped.
|
||||
pub fn force_set(&self, new: RuntimeState) {
|
||||
let prev = self.state.swap(new as u8, Ordering::AcqRel);
|
||||
let prev_state = RuntimeState::from_u8(prev).unwrap_or(RuntimeState::Stopped);
|
||||
if prev_state != new {
|
||||
tracing::info!(from = %prev_state, to = %new, "runtime state forced");
|
||||
/// Unconditionally advance the state forward. Used during emergency recovery
|
||||
/// when intermediate states are skipped.
|
||||
///
|
||||
/// Restricted to `pub(crate)` visibility to preserve lifecycle graph invariants.
|
||||
/// Guarantees monotonic forward movement (`new >= current_state`) and rejects
|
||||
/// backward state regressions.
|
||||
pub(crate) fn force_advance(&self, new: RuntimeState) {
|
||||
loop {
|
||||
let current = self.load();
|
||||
if (new as u8) < (current as u8) {
|
||||
tracing::warn!(
|
||||
current = %current,
|
||||
target = %new,
|
||||
"rejected state regression in force_advance"
|
||||
);
|
||||
break;
|
||||
}
|
||||
if current == new {
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(
|
||||
current as u8,
|
||||
new as u8,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
tracing::info!(from = %current, to = %new, "runtime state force advanced");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,9 +226,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_force_set() {
|
||||
fn test_invalid_graph_transition_rejected() {
|
||||
let state = AtomicRuntimeState::new();
|
||||
state.force_set(RuntimeState::ClosingResources);
|
||||
// Initializing -> ClosingResources is invalid in the normal graph
|
||||
assert!(
|
||||
state
|
||||
.transition(RuntimeState::Initializing, RuntimeState::ClosingResources)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(state.load(), RuntimeState::Initializing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_force_advance() {
|
||||
let state = AtomicRuntimeState::new();
|
||||
state.force_advance(RuntimeState::ClosingResources);
|
||||
assert_eq!(state.load(), RuntimeState::ClosingResources);
|
||||
|
||||
// State regression must be rejected
|
||||
state.force_advance(RuntimeState::Initializing);
|
||||
assert_eq!(state.load(), RuntimeState::ClosingResources);
|
||||
}
|
||||
|
||||
|
||||
+97
-2
@@ -6,6 +6,8 @@ use std::time::Duration;
|
||||
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
use super::ShutdownCoordinator;
|
||||
|
||||
pub struct TaskGroup {
|
||||
name: String,
|
||||
tasks: JoinSet<()>,
|
||||
@@ -100,11 +102,104 @@ impl WorkerManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&mut self, timeout: Duration) {
|
||||
pub async fn drain_all(&mut self) {
|
||||
for group in self.groups.values_mut() {
|
||||
group.shutdown(timeout).await;
|
||||
group.abort_all();
|
||||
while group.tasks.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shut down all worker groups concurrently under a single global deadline,
|
||||
/// while observing live forced shutdown escalation.
|
||||
pub async fn shutdown_all_with_coordinator(
|
||||
&mut self,
|
||||
timeout: Duration,
|
||||
coordinator: Option<&ShutdownCoordinator>,
|
||||
) {
|
||||
let active = self.active_tasks();
|
||||
if active == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
active_tasks = active,
|
||||
timeout_secs = timeout.as_secs(),
|
||||
"shutting down background worker task groups under global deadline"
|
||||
);
|
||||
|
||||
let is_already_forced = coordinator.map(|c| c.is_forced()).unwrap_or(false);
|
||||
if is_already_forced {
|
||||
tracing::warn!("forced shutdown active; aborting all worker tasks immediately");
|
||||
self.drain_all().await;
|
||||
return;
|
||||
}
|
||||
|
||||
let groups = std::mem::take(&mut self.groups);
|
||||
let mut group_joiners = JoinSet::new();
|
||||
let mut group_map = HashMap::new();
|
||||
|
||||
for (name, mut group) in groups {
|
||||
group_joiners.spawn(async move {
|
||||
while group.tasks.join_next().await.is_some() {}
|
||||
(name, group)
|
||||
});
|
||||
}
|
||||
|
||||
let join_all_fut = async {
|
||||
while let Some(res) = group_joiners.join_next().await {
|
||||
if let Ok((name, group)) = res {
|
||||
group_map.insert(name, group);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let forced_fut = async {
|
||||
if let Some(coord) = coordinator {
|
||||
coord.forced_cancelled().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = join_all_fut => {
|
||||
tracing::info!("all worker task groups shut down cleanly");
|
||||
}
|
||||
_ = tokio::time::sleep(timeout) => {
|
||||
tracing::warn!("global worker shutdown timeout expired; aborting remaining tasks");
|
||||
group_joiners.abort_all();
|
||||
while let Some(res) = group_joiners.join_next().await {
|
||||
if let Ok((name, mut group)) = res {
|
||||
group.abort_all();
|
||||
group_map.insert(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = forced_fut => {
|
||||
tracing::warn!("live forced shutdown escalation received during worker wait; aborting remaining tasks immediately");
|
||||
group_joiners.abort_all();
|
||||
while let Some(res) = group_joiners.join_next().await {
|
||||
if let Ok((name, mut group)) = res {
|
||||
group.abort_all();
|
||||
group_map.insert(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group in group_map.values_mut() {
|
||||
if !group.is_empty() {
|
||||
group.abort_all();
|
||||
while group.tasks.join_next().await.is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
self.groups = group_map;
|
||||
}
|
||||
|
||||
pub async fn shutdown_all(&mut self, timeout: Duration) {
|
||||
self.shutdown_all_with_coordinator(timeout, None).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkerManager {
|
||||
|
||||
+40
-60
@@ -2,13 +2,11 @@ use argon2::{
|
||||
Argon2, Params,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
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<String, AppError> {
|
||||
let params = Argon2::new(
|
||||
argon2::Algorithm::Argon2id,
|
||||
@@ -37,9 +35,6 @@ pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, App
|
||||
}
|
||||
|
||||
/// 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<bool, AppError> {
|
||||
let parsed = PasswordHash::new(hash).map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to parse password hash");
|
||||
@@ -57,19 +52,17 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
// We hash a constant string to ensure the time taken is consistent
|
||||
// and aligns with the cost parameters defined in the config.
|
||||
let _ = hash_password(
|
||||
"dummy_password_for_timing_attacks_constant_time_alignment",
|
||||
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 {
|
||||
@@ -79,7 +72,9 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
||||
}
|
||||
|
||||
let normalized = password.to_lowercase();
|
||||
let weak_list = [
|
||||
|
||||
// 1. Exact matches for highly common passwords
|
||||
let exact_weak: HashSet<&str> = [
|
||||
"password",
|
||||
"admin123",
|
||||
"qwerty",
|
||||
@@ -88,56 +83,41 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
|
||||
"administrator",
|
||||
"nx9-auth",
|
||||
"nx9auth",
|
||||
];
|
||||
"password123",
|
||||
"admin",
|
||||
"letmein",
|
||||
"welcome",
|
||||
"password12345",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for weak in &weak_list {
|
||||
if normalized.contains(weak) {
|
||||
if exact_weak.contains(normalized.as_str()) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password is too common or weak".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Substring matches for highly restricted roots
|
||||
// We ban these substrings because "password12345" or "qwerty2024" are trivially guessable.
|
||||
let banned_substrings = ["password", "qwerty", "123456"];
|
||||
for banned in &banned_substrings {
|
||||
if normalized.contains(banned) {
|
||||
return Err(AppError::InvalidInput(
|
||||
"password contains a weak or common sequence".to_string(),
|
||||
"password contains a restricted sequence".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reject single repeated characters
|
||||
if password
|
||||
.chars()
|
||||
.all(|c| c == password.chars().next().unwrap())
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"password cannot be a single repeated character".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());
|
||||
}
|
||||
}
|
||||
+22
-69
@@ -28,6 +28,11 @@ impl IpState {
|
||||
locked_until: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this state is no longer active and can be removed.
|
||||
fn is_stale(&self, now: Instant) -> bool {
|
||||
self.window.is_empty() && self.locked_until.is_none_or(|until| now >= until)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory escalating rate limiter for login attempts.
|
||||
@@ -37,9 +42,6 @@ impl IpState {
|
||||
/// - 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<IpAddr, IpState>,
|
||||
@@ -68,32 +70,26 @@ impl RateLimiter {
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
if let Some(s) = self.state.get(&ip)
|
||||
&& let Some(until) = s.locked_until
|
||||
&& 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;
|
||||
}
|
||||
if let Some(until) = s.locked_until
|
||||
&& now >= until
|
||||
{
|
||||
s.locked_until = None;
|
||||
}
|
||||
|
||||
// Prune old failures outside the window
|
||||
@@ -127,6 +123,14 @@ impl RateLimiter {
|
||||
// Do NOT reset lockout_count — escalation persists across successful logins
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodically sweep the rate limiter to remove stale entries and prevent memory leaks.
|
||||
/// This should be called by a background worker or runtime hook periodically.
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
// DashMap retain is efficient for this
|
||||
self.state.retain(|_, state| !state.is_stale(now));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
@@ -138,54 +142,3 @@ impl Default for RateLimiter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,15 +77,15 @@ pub async fn validate_session(
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
// Check absolute expiry
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||
if now > expires {
|
||||
provider
|
||||
.sessions()
|
||||
.revoke(&session.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
return Ok(None);
|
||||
}
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at)
|
||||
&& now > expires
|
||||
{
|
||||
provider
|
||||
.sessions()
|
||||
.revoke(&session.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Check idle timeout
|
||||
|
||||
@@ -130,12 +130,11 @@ pub async fn validate_token(
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if let Some(ref exp) = token.expires_at
|
||||
&& let Ok(expires) = chrono::DateTime::parse_from_rfc3339(exp)
|
||||
&& chrono::Utc::now() > expires
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Touch last_used_at (fire-and-forget)
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use nx9_auth::{
|
||||
api,
|
||||
config::{Config, DatabaseConfig, SecurityConfig, ServerConfig},
|
||||
db::{self, models::Tenant, provider::SqliteProvider},
|
||||
error::AppError,
|
||||
identity::{applications, roles, users},
|
||||
security::sessions,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
async fn setup_test_db() -> (
|
||||
Arc<dyn db::provider::DatabaseProvider>,
|
||||
sqlx::SqlitePool,
|
||||
String,
|
||||
) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_app_{}.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");
|
||||
let provider = Arc::new(SqliteProvider::new(pool.clone()));
|
||||
(provider, 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,
|
||||
argon2_iterations: 1,
|
||||
argon2_parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config(db_path: String) -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port: 8655,
|
||||
cookie_secure: false,
|
||||
production: false,
|
||||
},
|
||||
database: DatabaseConfig {
|
||||
path: Some(db_path),
|
||||
..Default::default()
|
||||
},
|
||||
security: test_security_config(),
|
||||
audit: nx9_auth::config::AuditConfig { enabled: true },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_app() -> (axum::Router, String, String, String) {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
let config = test_config(db_path.clone());
|
||||
let sec_cfg = config.security.clone();
|
||||
|
||||
let admin = users::create_user(
|
||||
&provider,
|
||||
&sec_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"admin_app_user",
|
||||
"AdminSecret123!",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
roles::assign_role(&provider, &admin.id, "admin", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (_session, raw_token) = sessions::create_session(
|
||||
&provider,
|
||||
&admin.id,
|
||||
Some("127.0.0.1"),
|
||||
Some("TestUA"),
|
||||
&sec_cfg,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = AppState::new(provider.clone(), config);
|
||||
let router = api::router::build(state);
|
||||
(router, admin.id, raw_token, db_path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_credential_generation_and_validation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let client_id = applications::generate_client_id();
|
||||
assert!(client_id.starts_with("nx9_app_"));
|
||||
assert_eq!(client_id.len(), 40); // nx9_app_ (8) + 32 hex chars = 40
|
||||
|
||||
let client_secret = applications::generate_client_secret();
|
||||
assert!(client_secret.starts_with("nx9_secret_"));
|
||||
assert_eq!(client_secret.len(), 75); // nx9_secret_ (11) + 64 hex chars = 75
|
||||
|
||||
let (app, raw_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Test App",
|
||||
"test-app",
|
||||
Some("Description of Test App"),
|
||||
Some(vec!["https://example.com/callback".into()]),
|
||||
Some(vec!["openid".into(), "profile".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(app.get_client_id().starts_with("nx9_app_"));
|
||||
assert!(app.has_credentials());
|
||||
assert_eq!(app.redirect_urls(), vec!["https://example.com/callback"]);
|
||||
assert_eq!(app.scopes(), vec!["openid", "profile"]);
|
||||
|
||||
// Secret hash in DB must be hex encoded BLAKE3 digest, not plaintext secret
|
||||
assert_ne!(app.client_secret_hash.as_ref().unwrap(), &raw_secret);
|
||||
|
||||
// Valid credentials authentication
|
||||
let validated =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &raw_secret)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validated.id, app.id);
|
||||
|
||||
// Invalid secret
|
||||
let invalid_sec = applications::validate_client_credentials(
|
||||
&provider,
|
||||
app.get_client_id(),
|
||||
"nx9_secret_invalid",
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(invalid_sec, Err(AppError::Unauthorized)));
|
||||
|
||||
// Unknown client_id
|
||||
let unknown_client =
|
||||
applications::validate_client_credentials(&provider, "nx9_app_nonexistent", &raw_secret)
|
||||
.await;
|
||||
assert!(matches!(unknown_client, Err(AppError::Unauthorized)));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_secret_rotation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, old_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rotate App",
|
||||
"rotate-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let new_secret = applications::rotate_secret(&provider, &app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old_secret, new_secret);
|
||||
|
||||
// Old secret fails
|
||||
let old_val =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(matches!(old_val, Err(AppError::Unauthorized)));
|
||||
|
||||
// New secret succeeds
|
||||
let new_val =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &new_secret)
|
||||
.await;
|
||||
assert!(new_val.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_api_endpoints_and_cache_control() {
|
||||
let (app_router, _user_id, token, db_path) = setup_app().await;
|
||||
|
||||
// 1. Create Application API
|
||||
let req_body = serde_json::json!({
|
||||
"name": "API Test App",
|
||||
"slug": "api-test-app",
|
||||
"description": "App built for API testing",
|
||||
"redirect_urls": ["https://app.test/cb"],
|
||||
"scopes": ["openid", "profile"]
|
||||
});
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/applications")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::from(serde_json::to_vec(&req_body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app_router.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
resp.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
|
||||
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let create_resp: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||
let app_obj = &create_resp["application"];
|
||||
let client_id = app_obj["client_id"].as_str().unwrap().to_string();
|
||||
let app_id = app_obj["id"].as_str().unwrap().to_string();
|
||||
let client_secret = create_resp["client_secret"].as_str().unwrap().to_string();
|
||||
|
||||
assert!(client_id.starts_with("nx9_app_"));
|
||||
assert!(client_secret.starts_with("nx9_secret_"));
|
||||
|
||||
// 2. GET Application API (Must NOT expose secret or secret hash)
|
||||
let get_req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!("/api/v1/applications/{app_id}"))
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let get_resp = app_router.clone().oneshot(get_req).await.unwrap();
|
||||
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||
let get_bytes = get_resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let get_json: Value = serde_json::from_slice(&get_bytes).unwrap();
|
||||
let get_app = &get_json["application"];
|
||||
|
||||
assert_eq!(get_app["client_id"], client_id);
|
||||
assert!(get_app.get("client_secret").is_none());
|
||||
assert!(get_app.get("client_secret_hash").is_none());
|
||||
assert_eq!(get_app["credentials_configured"], true);
|
||||
|
||||
// 3. PATCH Application containing `client_id` MUST be rejected by `deny_unknown_fields`
|
||||
let patch_invalid = serde_json::json!({
|
||||
"name": "Updated Name",
|
||||
"slug": "api-test-app",
|
||||
"client_id": "nx9_app_hack_attempt",
|
||||
"enabled": true
|
||||
});
|
||||
|
||||
let patch_req = Request::builder()
|
||||
.method("PATCH")
|
||||
.uri(format!("/api/v1/applications/{app_id}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::from(serde_json::to_vec(&patch_invalid).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let patch_resp = app_router.clone().oneshot(patch_req).await.unwrap();
|
||||
assert!(patch_resp.status().is_client_error()); // 400 / 422 Bad Request due to deny_unknown_fields
|
||||
|
||||
// 4. Rotate Secret API
|
||||
let rotate_req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!("/api/v1/applications/{app_id}/secret"))
|
||||
.header(header::COOKIE, format!("nx9_session={token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let rotate_resp = app_router.clone().oneshot(rotate_req).await.unwrap();
|
||||
assert_eq!(rotate_resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
rotate_resp
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
|
||||
let rotate_bytes = rotate_resp.into_body().collect().await.unwrap().to_bytes();
|
||||
let rotate_json: Value = serde_json::from_slice(&rotate_bytes).unwrap();
|
||||
let new_secret = rotate_json["client_secret"].as_str().unwrap();
|
||||
assert!(new_secret.starts_with("nx9_secret_"));
|
||||
assert_ne!(new_secret, client_secret);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_database_migration_backfill_and_upgrade() {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_upgrade_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path).await.unwrap();
|
||||
|
||||
// Execute migrations up to 0016 manually to simulate a v0.3.0 existing database
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0001_create_tenants.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0002_create_users.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0003_create_user_profiles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0004_create_roles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0005_create_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0006_create_role_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0007_create_user_roles.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0008_create_sessions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0009_create_api_tokens.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0010_create_service_accounts.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0011_create_applications.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0012_create_audit_logs.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0013_seed_default_tenant.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0014_seed_roles_and_permissions.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0015_create_refresh_tokens.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0016_create_groups.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let legacy_id = "30000000-0000-0000-0000-000000000099";
|
||||
sqlx::query("INSERT INTO applications (id, tenant_id, name, slug) VALUES (?, '00000000-0000-0000-0000-000000000001', 'Legacy App', 'legacy-app')")
|
||||
.bind(legacy_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Now run migration 0017 and 0018
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0017_update_applications_credentials.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(include_str!(
|
||||
"../src/db/migrations/sqlite/0018_harden_application_credentials.sql"
|
||||
))
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider: Arc<dyn db::provider::DatabaseProvider> = Arc::new(SqliteProvider::new(pool));
|
||||
let legacy_app = applications::get(&provider, legacy_id).await.unwrap();
|
||||
|
||||
assert_eq!(legacy_app.name, "Legacy App");
|
||||
assert_eq!(legacy_app.slug.as_deref(), Some("legacy-app"));
|
||||
assert!(legacy_app.get_client_id().starts_with("nx9_app_"));
|
||||
assert!(!legacy_app.has_credentials());
|
||||
|
||||
// Administrator performs secret rotation to generate credentials
|
||||
let generated_secret = applications::rotate_secret(&provider, &legacy_app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let updated_legacy = applications::get(&provider, legacy_id).await.unwrap();
|
||||
assert!(updated_legacy.has_credentials());
|
||||
|
||||
// Validate generated credentials
|
||||
let auth_res = applications::validate_client_credentials(
|
||||
&provider,
|
||||
updated_legacy.get_client_id(),
|
||||
&generated_secret,
|
||||
)
|
||||
.await;
|
||||
assert!(auth_res.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_creation_transactional_rollback_on_audit_failure() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
// Force audit log foreign-key failure by passing invalid actor_id
|
||||
let res = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rollback App",
|
||||
"rollback-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("non_existent_actor_id_fk"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
// Verify application record was NOT created in DB
|
||||
let app_opt = applications::find_by_slug(&provider, "rollback-app").await;
|
||||
assert!(matches!(app_opt, Err(AppError::NotFound)));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_secret_rotation_transactional_rollback_on_audit_failure() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, old_secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Rotate Rollback App",
|
||||
"rotate-rollback-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let orig_hash = app.client_secret_hash.clone().unwrap();
|
||||
|
||||
// Force audit insertion failure during rotation
|
||||
let fail_res = applications::rotate_secret(
|
||||
&provider,
|
||||
&app.id,
|
||||
Some("non_existent_actor_id_fk"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(fail_res.is_err());
|
||||
|
||||
// Assert stored client_secret_hash in DB remains UNCHANGED
|
||||
let app_after_failed_rotation = applications::get(&provider, &app.id).await.unwrap();
|
||||
assert_eq!(
|
||||
app_after_failed_rotation
|
||||
.client_secret_hash
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
&orig_hash
|
||||
);
|
||||
|
||||
// Assert original secret STILL authenticates successfully
|
||||
let orig_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(orig_auth.is_ok());
|
||||
|
||||
// Perform successful rotation
|
||||
let new_secret = applications::rotate_secret(&provider, &app.id, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Old secret fails, new secret succeeds
|
||||
let old_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &old_secret)
|
||||
.await;
|
||||
assert!(matches!(old_auth, Err(AppError::Unauthorized)));
|
||||
|
||||
let new_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &new_secret)
|
||||
.await;
|
||||
assert!(new_auth.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_authentication_slug_rejection() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
let (app, secret) = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Slug Reject App",
|
||||
"slug-reject-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Authentication by slug MUST fail
|
||||
let slug_auth =
|
||||
applications::validate_client_credentials(&provider, "slug-reject-app", &secret).await;
|
||||
assert!(matches!(slug_auth, Err(AppError::Unauthorized)));
|
||||
|
||||
// Authentication by client_id MUST succeed
|
||||
let client_id_auth =
|
||||
applications::validate_client_credentials(&provider, app.get_client_id(), &secret).await;
|
||||
assert!(client_id_auth.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_redirect_uri_structural_validation() {
|
||||
let (provider, _pool, db_path) = setup_test_db().await;
|
||||
|
||||
// 1. Malformed URI
|
||||
let malformed = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 1",
|
||||
"app-1",
|
||||
None,
|
||||
Some(vec!["not-a-valid-uri".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(malformed, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 2. Fragment URI
|
||||
let fragment = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 2",
|
||||
"app-2",
|
||||
None,
|
||||
Some(vec!["https://example.com/callback#frag".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(fragment, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 3. Userinfo URI
|
||||
let userinfo = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 3",
|
||||
"app-3",
|
||||
None,
|
||||
Some(vec!["https://user:pass@example.com/callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(userinfo, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 4. Non-loopback HTTP URI (must be rejected)
|
||||
let non_loopback_http = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 4",
|
||||
"app-4",
|
||||
None,
|
||||
Some(vec!["http://example.com/callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(non_loopback_http, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 5. Custom scheme (must be rejected)
|
||||
let custom_scheme = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 5",
|
||||
"app-5",
|
||||
None,
|
||||
Some(vec!["myapp://callback".into()]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(custom_scheme, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 6. >10 URIs
|
||||
let too_many_uris: Vec<String> = (0..11)
|
||||
.map(|i| format!("https://example{i}.com/cb"))
|
||||
.collect();
|
||||
let too_many = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"App 6",
|
||||
"app-6",
|
||||
None,
|
||||
Some(too_many_uris),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(too_many, Err(AppError::InvalidInput(_))));
|
||||
|
||||
// 7. Valid URIs (https and http loopback)
|
||||
let valid = applications::create(
|
||||
&provider,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Valid App",
|
||||
"valid-app",
|
||||
None,
|
||||
Some(vec![
|
||||
"https://app.example.com/callback".into(),
|
||||
"http://127.0.0.1:8080/callback".into(),
|
||||
"http://localhost:3000/callback".into(),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(valid.is_ok());
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{application_members, applications, users};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_app_members_{}.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 migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_membership_add_update_remove_transactions() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
// Create user and application in Default Tenant
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"app_user_1",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (app, _) = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Portal App",
|
||||
"portal-app",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Add membership atomically with audit log
|
||||
let member = application_members::add(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some("member"),
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(member.application_id, app.id);
|
||||
assert_eq!(member.user_id, user.id);
|
||||
assert_eq!(member.role, "member");
|
||||
|
||||
let audit_add = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("application".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let add_event = audit_add
|
||||
.into_iter()
|
||||
.find(|e| e.action == "application.member_added")
|
||||
.expect("member_added audit record must exist");
|
||||
assert_eq!(add_event.target_user_id.as_deref(), Some(user.id.as_str()));
|
||||
|
||||
// 2. Update membership role atomically with audit log
|
||||
let updated_member = application_members::update(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some("admin"),
|
||||
None,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(updated_member.role, "admin");
|
||||
|
||||
// 3. Remove membership atomically with audit log
|
||||
application_members::remove(
|
||||
&provider_dyn,
|
||||
&app.id,
|
||||
&user.id,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining_members = provider
|
||||
.application_members()
|
||||
.list_by_application(&app.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining_members.len(), 0);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_membership_same_tenant_isolation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b-app"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Default Tenant
|
||||
let user_a = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"user_tenant_a",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant B
|
||||
let (app_b, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_b,
|
||||
"App Tenant B",
|
||||
"app-tenant-b",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempting to assign user_a (Tenant Default) to app_b (Tenant B) MUST be rejected
|
||||
let res = application_members::add(
|
||||
&provider_dyn,
|
||||
&app_b.id,
|
||||
&user_a.id,
|
||||
Some("member"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Cross-tenant application membership assignment MUST be rejected"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_audit_export_{}.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 migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_list_remains_clamped_to_500() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 600 audit events
|
||||
for i in 0..600 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&format!("{{\"index\": {i}}}")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Normal list filter with limit=1000 MUST be clamped to 500 by backend API logic
|
||||
let filter = nx9_auth::db::models::AuditFilter {
|
||||
limit: 1000,
|
||||
..Default::default()
|
||||
};
|
||||
let clamped_limit = filter.limit.clamp(1, 500);
|
||||
assert_eq!(clamped_limit, 500);
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
limit: clamped_limit,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
500,
|
||||
"Normal audit listing must be bounded to 500 records max"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_export_can_return_more_than_500_up_to_5000() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 600 audit events
|
||||
for i in 0..600 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&format!("{{\"index\": {i}}}")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Export query path with limit=5000 returns all 600 records (>500)
|
||||
let export_filter = nx9_auth::db::models::AuditFilter {
|
||||
limit: 5000,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&export_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
600,
|
||||
"Export query path must return >500 records when available"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_audit_export_hard_bounded_at_5000() {
|
||||
let (_provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Request limit 999999 must be clamped to hard maximum 5000
|
||||
let requested_limit = 999999i64;
|
||||
let export_limit = requested_limit.clamp(1, 5000);
|
||||
assert_eq!(export_limit, 5000);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_side_success_filtering_before_limit_and_count() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Insert 10 success events and 10 failure events
|
||||
for _ in 0..10 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"user.login",
|
||||
"user",
|
||||
None,
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
for _ in 0..10 {
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
None,
|
||||
None,
|
||||
"auth.failed",
|
||||
"user",
|
||||
None,
|
||||
"warning",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Server-side filter success = true
|
||||
let success_filter = nx9_auth::db::models::AuditFilter {
|
||||
success: Some(true),
|
||||
limit: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let count = provider
|
||||
.audit()
|
||||
.count_filtered(&success_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&success_filter)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(count, 10);
|
||||
assert_eq!(entries.len(), 10);
|
||||
assert!(entries.iter().all(|e| !e.action.contains("fail")));
|
||||
|
||||
// Server-side filter success = false
|
||||
let fail_filter = nx9_auth::db::models::AuditFilter {
|
||||
success: Some(false),
|
||||
limit: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let fail_count = provider.audit().count_filtered(&fail_filter).await.unwrap();
|
||||
let fail_entries = provider.audit().list_filtered(&fail_filter).await.unwrap();
|
||||
|
||||
assert_eq!(fail_count, 10);
|
||||
assert_eq!(fail_entries.len(), 10);
|
||||
assert!(fail_entries.iter().all(|e| e.action.contains("fail")));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rfc4180_csv_escaping_rules() {
|
||||
let esc = |s: &str| format!("\"{}\"", s.replace('"', "\"\""));
|
||||
|
||||
assert_eq!(esc("simple"), "\"simple\"");
|
||||
assert_eq!(esc("with,comma"), "\"with,comma\"");
|
||||
assert_eq!(esc("with \"quotes\""), "\"with \"\"quotes\"\"\"");
|
||||
assert_eq!(esc("multi\nline"), "\"multi\nline\"");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_exact_resource_id_filter_excludes_generic_text_matches() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let tenant_id = "tenant-exact";
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
None,
|
||||
"tenant.updated",
|
||||
"tenant",
|
||||
Some(tenant_id),
|
||||
"info",
|
||||
None,
|
||||
None,
|
||||
Some("{}"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
None,
|
||||
"tenant.updated",
|
||||
"tenant",
|
||||
Some("other-tenant"),
|
||||
"info",
|
||||
None,
|
||||
None,
|
||||
Some(&format!("{{\"mentioned\":\"{tenant_id}\"}}")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let filter = nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("tenant".into()),
|
||||
resource_id: Some(tenant_id.into()),
|
||||
limit: 50,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(provider.audit().count_filtered(&filter).await.unwrap(), 1);
|
||||
let entries = provider.audit().list_filtered(&filter).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].resource_id.as_deref(), Some(tenant_id));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#![cfg(feature = "postgres")]
|
||||
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_postgres_fresh_migration_from_0001_to_latest() {
|
||||
let database_url = "postgres://postgres@127.0.0.1:5433/nx9_auth_test_fresh";
|
||||
|
||||
let pool = match PgPoolOptions::new()
|
||||
.acquire_timeout(Duration::from_secs(1))
|
||||
.connect(database_url)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("Skipping PostgreSQL live connection test (server not running): {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let migrator = sqlx::migrate!("src/db/migrations/postgres");
|
||||
let res = migrator.run(&pool).await;
|
||||
|
||||
assert!(res.is_ok(), "Fresh PostgreSQL migration failed: {:?}", res);
|
||||
|
||||
// Test idempotency (re-running on migrated database)
|
||||
let res_idempotent = migrator.run(&pool).await;
|
||||
assert!(
|
||||
res_idempotent.is_ok(),
|
||||
"Re-running PostgreSQL migrations failed: {:?}",
|
||||
res_idempotent
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nx9_auth::config::Config;
|
||||
use nx9_auth::runtime::{
|
||||
Application, HookRegistry, RuntimeState, ShutdownHook, ShutdownPriority, WorkerManager,
|
||||
Application, HookRegistry, Lifecycle, RuntimeState, ShutdownCoordinator, ShutdownHook,
|
||||
ShutdownPriority, WorkerManager,
|
||||
};
|
||||
|
||||
struct TestHook {
|
||||
name: &'static str,
|
||||
priority: ShutdownPriority,
|
||||
should_fail: bool,
|
||||
counter: Arc<AtomicUsize>,
|
||||
sequence: Arc<tokio::sync::Mutex<Vec<&'static str>>>,
|
||||
}
|
||||
@@ -28,6 +30,9 @@ impl ShutdownHook for TestHook {
|
||||
self.counter.fetch_add(1, Ordering::SeqCst);
|
||||
let mut seq = self.sequence.lock().await;
|
||||
seq.push(self.name);
|
||||
if self.should_fail {
|
||||
anyhow::bail!("deliberate hook failure");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -55,18 +60,21 @@ async fn test_shutdown_hook_execution_order() {
|
||||
let hook_last = TestHook {
|
||||
name: "hook_last",
|
||||
priority: ShutdownPriority::Last,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_first = TestHook {
|
||||
name: "hook_first",
|
||||
priority: ShutdownPriority::First,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_normal = TestHook {
|
||||
name: "hook_normal",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
@@ -85,6 +93,74 @@ async fn test_shutdown_hook_execution_order() {
|
||||
assert_eq!(*seq, vec!["hook_first", "hook_normal", "hook_last"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_priority_hook_registration_order() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let sequence = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let hook_n1 = TestHook {
|
||||
name: "normal_1",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_n2 = TestHook {
|
||||
name: "normal_2",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let hook_n3 = TestHook {
|
||||
name: "normal_3",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
|
||||
let mut registry = HookRegistry::new();
|
||||
registry.register(Box::new(hook_n1));
|
||||
registry.register(Box::new(hook_n2));
|
||||
registry.register(Box::new(hook_n3));
|
||||
|
||||
registry.execute_all().await;
|
||||
let seq = sequence.lock().await;
|
||||
assert_eq!(*seq, vec!["normal_1", "normal_2", "normal_3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hook_failure_resilience() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let sequence = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
|
||||
let failing_hook = TestHook {
|
||||
name: "failing_hook",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: true,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
let succeeding_hook = TestHook {
|
||||
name: "succeeding_hook",
|
||||
priority: ShutdownPriority::Normal,
|
||||
should_fail: false,
|
||||
counter: counter.clone(),
|
||||
sequence: sequence.clone(),
|
||||
};
|
||||
|
||||
let mut registry = HookRegistry::new();
|
||||
registry.register(Box::new(failing_hook));
|
||||
registry.register(Box::new(succeeding_hook));
|
||||
|
||||
registry.execute_all().await;
|
||||
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
let seq = sequence.lock().await;
|
||||
assert_eq!(*seq, vec!["failing_hook", "succeeding_hook"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_manager_lifecycle() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
@@ -102,3 +178,154 @@ async fn test_worker_manager_lifecycle() {
|
||||
assert_eq!(mgr.active_tasks(), 0);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_live_forced_escalation_abort() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
let group = mgr.group("long-worker");
|
||||
|
||||
let worker_started = Arc::new(AtomicBool::new(false));
|
||||
let started = worker_started.clone();
|
||||
|
||||
group.spawn(async move {
|
||||
started.store(true, Ordering::SeqCst);
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
|
||||
// Wait for worker to begin execution
|
||||
while !worker_started.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 1);
|
||||
|
||||
let coord = ShutdownCoordinator::new();
|
||||
let coord_clone = coord.clone();
|
||||
|
||||
let start_time = Instant::now();
|
||||
|
||||
let shutdown_handle = tokio::spawn(async move {
|
||||
let mut m = mgr;
|
||||
m.shutdown_all_with_coordinator(Duration::from_secs(10), Some(&coord_clone))
|
||||
.await;
|
||||
m
|
||||
});
|
||||
|
||||
// Short delay to ensure shutdown_all is actively waiting
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
|
||||
// Trigger live second-signal forced escalation
|
||||
coord.cancel_forced();
|
||||
|
||||
let mgr_after = shutdown_handle.await.expect("shutdown task join");
|
||||
let elapsed = start_time.elapsed();
|
||||
|
||||
assert_eq!(mgr_after.active_tasks(), 0);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1000),
|
||||
"Forced shutdown took {:?}, expected < 1s",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_worker_global_deadline_budget_across_groups() {
|
||||
let mut mgr = WorkerManager::new();
|
||||
mgr.group("group-a").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
mgr.group("group-b").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
mgr.group("group-c").spawn(async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
});
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 3);
|
||||
|
||||
let start = Instant::now();
|
||||
mgr.shutdown_all(Duration::from_millis(200)).await;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert_eq!(mgr.active_tasks(), 0);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(800),
|
||||
"Worker budget timeout across 3 groups took {:?}, expected single global deadline (~200ms)",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forced_http_draining_escalation() -> anyhow::Result<()> {
|
||||
use axum::routing::get;
|
||||
|
||||
let router = axum::Router::new().route(
|
||||
"/slow",
|
||||
get(|| async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
"done"
|
||||
}),
|
||||
);
|
||||
|
||||
let mut config = Config::default();
|
||||
config.server.host = "127.0.0.1".to_string();
|
||||
config.server.port = 0;
|
||||
config.database.url = Some("sqlite::memory:".to_string());
|
||||
|
||||
let mut app = Application::builder(config).build().await?;
|
||||
app.router = Some(router);
|
||||
|
||||
let coord = app.shutdown_coordinator().clone();
|
||||
let state_ref = app.state.clone();
|
||||
let port_ref = app.bound_port.clone();
|
||||
|
||||
let app_task = tokio::spawn(async move { app.start().await });
|
||||
|
||||
// Wait for server task to bind and store bound_port
|
||||
while port_ref.load(Ordering::Acquire) == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
let port = port_ref.load(Ordering::Acquire);
|
||||
|
||||
// Send HTTP request to /slow in background task (will take 10s if not aborted)
|
||||
let req_task = tokio::spawn(async move {
|
||||
if let Ok(mut stream) = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stream
|
||||
.write_all(b"GET /slow HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
||||
.await;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Short delay for request to arrive at server
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
|
||||
// Trigger 1st signal (graceful shutdown)
|
||||
coord.cancel_graceful();
|
||||
|
||||
// Allow Tokio task executor to process cancellation and transition to Draining
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
// Verify RuntimeState is Draining while request is in-flight
|
||||
assert_eq!(state_ref.load(), RuntimeState::Draining);
|
||||
|
||||
// Trigger 2nd signal (forced escalation)
|
||||
let start = Instant::now();
|
||||
coord.cancel_forced();
|
||||
|
||||
let res = app_task.await?;
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
assert!(res.is_ok());
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(1000),
|
||||
"Forced HTTP shutdown took {:?}, expected < 1s",
|
||||
elapsed
|
||||
);
|
||||
|
||||
req_task.abort();
|
||||
Ok(())
|
||||
}
|
||||
@@ -73,7 +73,10 @@ fn test_config(db_path: String) -> Config {
|
||||
async fn test_security_no_plaintext_passwords_in_db() {
|
||||
let (provider, pool, db_path) = setup_test_db().await;
|
||||
let sec_cfg = test_security_config();
|
||||
let password = "super_secret_special_pass_123456";
|
||||
|
||||
//let password = "super_secret_special_pass_123456";
|
||||
// Use a strong, random string to bypass the restricted sequence validator
|
||||
let password = "Xy7#bN9@mK2$pQ5!vL8&zW4";
|
||||
|
||||
let user = identity_users::create_user(
|
||||
&provider,
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{applications, slug};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_slug_{}.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 migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_canonical_slug_validation_and_policy() {
|
||||
// Valid slugs
|
||||
assert!(slug::validate_slug("default").is_ok());
|
||||
assert!(slug::validate_slug("my-app-1").is_ok());
|
||||
assert!(slug::validate_slug("acme-tenant").is_ok());
|
||||
assert!(slug::validate_slug("ab").is_ok());
|
||||
|
||||
// Invalid length
|
||||
assert!(slug::validate_slug("a").is_err());
|
||||
let long_slug = "a".repeat(64);
|
||||
assert!(slug::validate_slug(&long_slug).is_err());
|
||||
|
||||
// Invalid formatting
|
||||
assert!(slug::validate_slug("-invalid").is_err());
|
||||
assert!(slug::validate_slug("invalid-").is_err());
|
||||
assert!(slug::validate_slug("in--valid").is_err());
|
||||
assert!(slug::validate_slug("Invalid").is_err());
|
||||
assert!(slug::validate_slug("in_valid").is_err());
|
||||
|
||||
// Reserved names
|
||||
assert!(slug::validate_slug("admin").is_err());
|
||||
assert!(slug::validate_slug("api").is_err());
|
||||
assert!(slug::validate_slug("system").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_explicit_invalid_slug_rejection_no_silent_slugify() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
// Explicit invalid slug must be rejected directly and NOT silently slugified
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
let res = provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "My Organization", Some("INVALID SLUG!"))
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "Explicit invalid slug should be rejected");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_omitted_slug_generation_on_create() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
let tenant = provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Acme Corporation Inc!", None)
|
||||
.await
|
||||
.expect("Should derive slug from name when omitted");
|
||||
|
||||
assert_eq!(tenant.slug.as_deref(), Some("acme-corporation-inc"));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_resource_duplicate_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id1 = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id1, "Tenant One", Some("tenant-one"))
|
||||
.await
|
||||
.expect("First tenant creation should succeed");
|
||||
|
||||
let id2 = uuid::Uuid::new_v4().to_string();
|
||||
let res = provider
|
||||
.tenants()
|
||||
.create(&id2, "Tenant Two", Some("tenant-one"))
|
||||
.await;
|
||||
|
||||
assert!(res.is_err(), "Duplicate tenant slug must be rejected");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_resource_collision_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||
|
||||
// Create a tenant with slug "shared-identifier"
|
||||
let t_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&t_id, "Shared Tenant", Some("shared-identifier"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Attempting to create an application with the SAME slug "shared-identifier" must fail
|
||||
let app_res = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Colliding Application",
|
||||
"shared-identifier",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
app_res.is_err(),
|
||||
"Cross-resource slug collision (app vs tenant) must be rejected"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unchanged_slug_update_no_op() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Original Name", Some("stable-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Updating tenant name while keeping the exact same slug must succeed (no-op for registry)
|
||||
let update_res = provider
|
||||
.tenants()
|
||||
.update(&id, "Updated Name", Some("stable-slug"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
update_res.is_ok(),
|
||||
"Unchanged slug update should succeed as no-op"
|
||||
);
|
||||
|
||||
let updated = provider.tenants().find_by_id(&id).await.unwrap().unwrap();
|
||||
assert_eq!(updated.name, "Updated Name");
|
||||
assert_eq!(updated.slug.as_deref(), Some("stable-slug"));
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rename_and_old_slug_release() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Alpha Tenant", Some("old-alpha-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Rename to new-alpha-slug
|
||||
provider
|
||||
.tenants()
|
||||
.update(&id, "Alpha Tenant", Some("new-alpha-slug"))
|
||||
.await
|
||||
.expect("Rename should succeed");
|
||||
|
||||
// Verify old-alpha-slug is released and can be claimed by another resource
|
||||
let id2 = uuid::Uuid::new_v4().to_string();
|
||||
let claim_res = provider
|
||||
.tenants()
|
||||
.create(&id2, "Beta Tenant", Some("old-alpha-slug"))
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
claim_res.is_ok(),
|
||||
"Released old slug should be claimable by new resource"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slug_release_ownership_verification() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id1 = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id1, "Tenant One", Some("tenant-slug-1"))
|
||||
.await
|
||||
.expect("Tenant 1 creation should succeed");
|
||||
|
||||
// Attempting to release tenant-slug-1 using a wrong entity_id (id2) directly via sqlite helper
|
||||
let mut tx = provider.pool.begin().await.unwrap();
|
||||
let rows = db::repository::sqlite::global_slugs::release_slug_by_name_sqlite(
|
||||
&mut tx,
|
||||
"tenant-slug-1",
|
||||
"tenant",
|
||||
"wrong-entity-id",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tx.commit().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rows, 0,
|
||||
"Release with wrong entity_id ownership must affect 0 rows"
|
||||
);
|
||||
|
||||
// Verify tenant-slug-1 is still registered in global_slugs
|
||||
let existing_slug = provider
|
||||
.global_slugs()
|
||||
.find_by_slug("tenant-slug-1")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
existing_slug.is_some(),
|
||||
"Registration must remain intact when release ownership fails"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_deletion_slug_release() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&id, "Temporary Tenant", Some("temp-tenant-slug"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
// Delete tenant
|
||||
provider
|
||||
.tenants()
|
||||
.delete(&id)
|
||||
.await
|
||||
.expect("Tenant deletion should succeed");
|
||||
|
||||
// Verify temp-tenant-slug is released from global_slugs
|
||||
let found = provider
|
||||
.global_slugs()
|
||||
.find_by_slug("temp-tenant-slug")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
found.is_none(),
|
||||
"Slug must be removed from global_slugs after deletion"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_application_slug_never_authenticates_as_client_id() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn nx9_auth::db::provider::DatabaseProvider> = provider.clone();
|
||||
|
||||
let (app, raw_secret) = applications::create(
|
||||
&provider_dyn,
|
||||
Tenant::DEFAULT_ID,
|
||||
"Auth App",
|
||||
"auth-app-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("App creation should succeed");
|
||||
|
||||
// Authenticating using canonical client_id must succeed
|
||||
let auth_ok =
|
||||
applications::validate_client_credentials(&provider_dyn, app.get_client_id(), &raw_secret)
|
||||
.await;
|
||||
assert!(
|
||||
auth_ok.is_ok(),
|
||||
"Authentication with client_id must succeed"
|
||||
);
|
||||
|
||||
// Authenticating using application SLUG as client_id MUST FAIL
|
||||
let auth_slug_fail =
|
||||
applications::validate_client_credentials(&provider_dyn, "auth-app-slug", &raw_secret)
|
||||
.await;
|
||||
assert!(
|
||||
auth_slug_fail.is_err(),
|
||||
"Application slug MUST NEVER authenticate as client_id"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dual_migration_paths_sqlite() {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_migration_path_{}.db", db_id);
|
||||
let pool = db::create_pool(&db_path).await.unwrap();
|
||||
|
||||
// Fresh migration
|
||||
let res = db::run_migrations(&pool).await;
|
||||
assert!(res.is_ok(), "Fresh migration must succeed");
|
||||
|
||||
// Idempotent re-run
|
||||
let res2 = db::run_migrations(&pool).await;
|
||||
assert!(res2.is_ok(), "Re-running migrations must succeed");
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
#![cfg(feature = "sqlite")]
|
||||
|
||||
use nx9_auth::db::{
|
||||
self,
|
||||
models::Tenant,
|
||||
provider::{DatabaseProvider, SqliteProvider},
|
||||
};
|
||||
use nx9_auth::identity::{applications, users};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn setup_test_provider() -> (Arc<SqliteProvider>, String) {
|
||||
let db_id = uuid::Uuid::new_v4().to_string();
|
||||
let db_path = format!("target/test_tenant_mgmt_{}.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 migrations");
|
||||
(Arc::new(SqliteProvider::new(pool)), db_path)
|
||||
}
|
||||
|
||||
async fn teardown_test_db(path: String) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_user_listing_and_assignment() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
// Create a new tenant
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Acme Org", Some("acme-org"))
|
||||
.await
|
||||
.expect("Tenant creation should succeed");
|
||||
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"employee_1",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("User creation should succeed");
|
||||
|
||||
assert_eq!(user.tenant_id, Tenant::DEFAULT_ID);
|
||||
|
||||
// Reassign user to Acme Org
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(&user.id, &tenant_id, None, None, None)
|
||||
.await
|
||||
.expect("Tenant assignment should succeed");
|
||||
|
||||
let tenant_users = provider
|
||||
.users()
|
||||
.list(&tenant_id)
|
||||
.await
|
||||
.expect("Listing tenant users should succeed");
|
||||
|
||||
assert_eq!(tenant_users.len(), 1);
|
||||
assert_eq!(tenant_users[0].username, "employee_1");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_assign_user_username_collision_rejection() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_id, "Beta Corp", Some("beta-corp"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Default tenant named "common_user"
|
||||
let u1 = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"common_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create user in Beta Corp also named "common_user"
|
||||
let _u2 = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
&tenant_id,
|
||||
"common_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Attempting to move u1 into Beta Corp must collide because "common_user" already exists in Beta Corp
|
||||
let exists = provider
|
||||
.users()
|
||||
.username_exists(&tenant_id, &u1.username)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
exists,
|
||||
"Username existence check must return true for colliding username in destination tenant"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tenant_application_listing_isolation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
|
||||
let tenant_a = uuid::Uuid::new_v4().to_string();
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_a, "Tenant A", Some("tenant-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant A
|
||||
let (app_a, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_a,
|
||||
"App A",
|
||||
"app-a-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create application in Tenant B
|
||||
let (app_b, _) = applications::create(
|
||||
&provider_dyn,
|
||||
&tenant_b,
|
||||
"App B",
|
||||
"app-b-slug",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let apps_a = provider.applications().list(&tenant_a).await.unwrap();
|
||||
let apps_b = provider.applications().list(&tenant_b).await.unwrap();
|
||||
|
||||
assert_eq!(apps_a.len(), 1);
|
||||
assert_eq!(apps_a[0].id, app_a.id);
|
||||
|
||||
assert_eq!(apps_b.len(), 1);
|
||||
assert_eq!(apps_b[0].id, app_b.id);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_identity_immediately_reflects_tenant_reassignment() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_b = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_b, "Tenant B", Some("tenant-b-session"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Create user in Default Tenant
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"session_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 2. Create active session for user
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let token_hash = "hash_123456";
|
||||
let expires_at = "2030-01-01T00:00:00Z";
|
||||
|
||||
provider
|
||||
.sessions()
|
||||
.create(
|
||||
&session_id,
|
||||
&user.id,
|
||||
token_hash,
|
||||
Some("127.0.0.1"),
|
||||
Some("TestAgent"),
|
||||
expires_at,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 3. Resolve user identity via session user_id -> tenant_id must be Default Tenant
|
||||
let session_user_before = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session_user_before.tenant_id, Tenant::DEFAULT_ID);
|
||||
|
||||
// 4. Reassign user to Tenant B
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(&user.id, &tenant_b, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 5. Subsequent request resolving user identity for the same active session MUST immediately yield Tenant B
|
||||
let session_user_after = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session_user_after.tenant_id, tenant_b);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prevent_last_admin_reassignment_validation() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
// Create an admin user
|
||||
let admin_user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"admin_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let admin_role = provider
|
||||
.roles()
|
||||
.find_by_name("admin")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("admin role must exist");
|
||||
provider
|
||||
.roles()
|
||||
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Check system admin count
|
||||
let admin_count = provider.users().count_admins().await.unwrap();
|
||||
assert_eq!(admin_count, 1, "Should count 1 system admin user");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassignment_audit_event_metadata_invariants() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"audit_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let audit_id = uuid::Uuid::new_v4().to_string();
|
||||
let from_tenant_id = Tenant::DEFAULT_ID;
|
||||
let to_tenant_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"from_tenant_id": from_tenant_id,
|
||||
"to_tenant_id": to_tenant_id,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider
|
||||
.audit()
|
||||
.insert(
|
||||
&audit_id,
|
||||
Some(&user.id),
|
||||
Some(&user.id),
|
||||
"user.tenant_reassigned",
|
||||
"user",
|
||||
Some(&user.id),
|
||||
"info",
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
Some(&metadata),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = entries
|
||||
.into_iter()
|
||||
.find(|e| e.id == audit_id)
|
||||
.expect("Audit event must exist");
|
||||
|
||||
assert_eq!(event.action, "user.tenant_reassigned");
|
||||
let parsed_meta: serde_json::Value =
|
||||
serde_json::from_str(event.metadata_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(parsed_meta["from_tenant_id"], from_tenant_id);
|
||||
assert_eq!(parsed_meta["to_tenant_id"], to_tenant_id);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassign_user_tenant_with_audit_atomic_success() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let tenant_dest = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&tenant_dest, "Dest Tenant", Some("dest-tenant"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"atomic_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Call atomic reassign
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
&tenant_dest,
|
||||
Some(&user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify tenant update
|
||||
let updated_user = provider
|
||||
.users()
|
||||
.find_by_id(&user.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated_user.tenant_id, tenant_dest);
|
||||
|
||||
// Verify audit record was inserted inside transaction
|
||||
let audit_entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let audit_event = audit_entries
|
||||
.into_iter()
|
||||
.find(|e| {
|
||||
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||
})
|
||||
.expect("Atomic reassignment audit event must exist");
|
||||
|
||||
let meta: serde_json::Value =
|
||||
serde_json::from_str(audit_event.metadata_json.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(meta["from_tenant_id"], Tenant::DEFAULT_ID);
|
||||
assert_eq!(meta["to_tenant_id"], tenant_dest);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reassign_user_tenant_no_op_behavior() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"noop_user",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reassigning to SAME tenant must be a defined no-op
|
||||
provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&user.id,
|
||||
Tenant::DEFAULT_ID,
|
||||
Some("actor_1"),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Audit logs MUST NOT contain a false tenant_reassigned event
|
||||
let audit_entries = provider
|
||||
.audit()
|
||||
.list_filtered(&nx9_auth::db::models::AuditFilter {
|
||||
resource_type: Some("user".to_string()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let noop_audit = audit_entries.into_iter().find(|e| {
|
||||
e.action == "user.tenant_reassigned" && e.target_user_id.as_deref() == Some(&user.id)
|
||||
});
|
||||
|
||||
assert!(
|
||||
noop_audit.is_none(),
|
||||
"No-op reassignment must NOT write a false audit log entry"
|
||||
);
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_admin_reassignment_cannot_remove_all_system_admins() {
|
||||
let (provider, db_path) = setup_test_provider().await;
|
||||
let provider_dyn: Arc<dyn DatabaseProvider> = provider.clone();
|
||||
let dummy_cfg = nx9_auth::config::SecurityConfig::default();
|
||||
|
||||
let dest_tenant = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
.tenants()
|
||||
.create(&dest_tenant, "Dest", Some("dest"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create single system admin in Default Tenant
|
||||
let admin_user = users::create_user(
|
||||
&provider_dyn,
|
||||
&dummy_cfg,
|
||||
Tenant::DEFAULT_ID,
|
||||
"single_admin",
|
||||
"X9#mK$9qL!2zP0",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let admin_role = provider
|
||||
.roles()
|
||||
.find_by_name("admin")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
provider
|
||||
.roles()
|
||||
.assign_to_user(&admin_user.id, &admin_role.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Single system admin reassignment away from Default Tenant MUST be rejected
|
||||
let res = provider
|
||||
.users()
|
||||
.reassign_user_tenant_with_audit(
|
||||
&admin_user.id,
|
||||
&dest_tenant,
|
||||
Some(&admin_user.id),
|
||||
Some("127.0.0.1"),
|
||||
Some("TestRunner"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"Moving the last system admin away from Default Tenant MUST fail"
|
||||
);
|
||||
|
||||
// Invariant check: system admin count MUST remain >= 1 in Default Tenant
|
||||
let admin_count = provider.users().count_admins().await.unwrap();
|
||||
assert_eq!(admin_count, 1, "System admin count must never drop to 0");
|
||||
|
||||
teardown_test_db(db_path).await;
|
||||
}
|
||||
Generated
+19
-19
@@ -4,9 +4,9 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -75,9 +75,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.3.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
checksum = "9066c49992464636f92905fa096ec58baaa4d57ec19a5c096c68d3e25ef3d136"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -271,9 +271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
@@ -651,13 +651,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -698,9 +698,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
@@ -916,9 +916,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.2"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@@ -1131,9 +1131,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
@@ -2233,18 +2233,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.55"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nx9-auth-ui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
authors = ["NX9 Team", "Sunil Thakare"]
|
||||
description = "Dioxus web UI for nx9-auth IAM"
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ pub fn App() -> Element {
|
||||
if !matches!(auth(), BootstrapState::Initializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
match api::me().await {
|
||||
Ok(Some(me)) => {
|
||||
auth.set(BootstrapState::Authenticated(me));
|
||||
|
||||
@@ -71,8 +71,7 @@ pub fn Modal(
|
||||
title: String,
|
||||
open: bool,
|
||||
on_close: EventHandler<()>,
|
||||
#[props(default)]
|
||||
large: bool,
|
||||
#[props(default)] large: bool,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
if !open {
|
||||
@@ -112,10 +111,8 @@ pub fn ConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
open: bool,
|
||||
#[props(default = "Confirm".to_string())]
|
||||
confirm_label: String,
|
||||
#[props(default)]
|
||||
danger: bool,
|
||||
#[props(default = "Confirm".to_string())] confirm_label: String,
|
||||
#[props(default)] danger: bool,
|
||||
on_confirm: EventHandler<()>,
|
||||
on_cancel: EventHandler<()>,
|
||||
) -> Element {
|
||||
|
||||
@@ -12,13 +12,26 @@ pub fn Header() -> Element {
|
||||
let auth = state.auth;
|
||||
let theme = state.theme;
|
||||
let mut menu_open = use_signal(|| false);
|
||||
let mut tenant_menu_open = use_signal(|| false);
|
||||
let mut quick_create_open = use_signal(|| false);
|
||||
let mut mobile = state.mobile_nav_open;
|
||||
|
||||
let username = auth().username().to_string();
|
||||
let theme_icon = theme().icon();
|
||||
let theme_label = theme().label();
|
||||
|
||||
let auth_state = state.auth.read();
|
||||
let can_create_user = auth_state.has_permission("users:create") || auth_state.is_adminish();
|
||||
let can_create_tenant = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||
let can_create_app =
|
||||
auth_state.has_permission("applications:manage") || auth_state.is_adminish();
|
||||
let can_create_role = auth_state.has_permission("roles:manage") || auth_state.is_adminish();
|
||||
let can_create_sa = auth_state.has_permission("service_accounts:manage")
|
||||
|| auth_state.has_permission("roles:manage")
|
||||
|| auth_state.is_adminish();
|
||||
let has_any_create =
|
||||
can_create_user || can_create_tenant || can_create_app || can_create_role || can_create_sa;
|
||||
drop(auth_state);
|
||||
|
||||
rsx! {
|
||||
header { class: "app-header",
|
||||
button {
|
||||
@@ -36,66 +49,97 @@ pub fn Header() -> Element {
|
||||
span { "nx9-auth" }
|
||||
}
|
||||
|
||||
// Tenant Switcher
|
||||
div { class: "dropdown", style: "margin-left: 1rem;",
|
||||
button {
|
||||
class: "btn btn-ghost",
|
||||
r#type: "button",
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": "{tenant_menu_open()}",
|
||||
onclick: move |_| tenant_menu_open.set(!tenant_menu_open()),
|
||||
span { class: "icon", "🏢" }
|
||||
span { style: "margin-left: 0.4rem; font-weight: 500;",
|
||||
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
|
||||
}
|
||||
span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" }
|
||||
// Tenant Indicator & Management Link
|
||||
div { class: "tenant-badge", style: "margin-left: 1rem; display: flex; align-items: center; gap: 0.5rem;",
|
||||
span { class: "icon", "🏢" }
|
||||
span { style: "font-weight: 500; font-size: 13px;",
|
||||
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
|
||||
}
|
||||
if tenant_menu_open() {
|
||||
div { class: "dropdown-menu", role: "menu",
|
||||
button { class: "dropdown-item", r#type: "button", "Default Tenant" }
|
||||
div { class: "dropdown-divider" }
|
||||
Link {
|
||||
class: "dropdown-item text-primary",
|
||||
to: Route::TenantsPage {},
|
||||
onclick: move |_| tenant_menu_open.set(false),
|
||||
"Manage tenants…"
|
||||
Link {
|
||||
class: "btn btn-xs btn-ghost text-primary",
|
||||
to: Route::TenantsPage {},
|
||||
"Manage tenants…"
|
||||
}
|
||||
}
|
||||
|
||||
div { style: "flex: 1;" }
|
||||
|
||||
div { class: "header-actions",
|
||||
if has_any_create {
|
||||
div { class: "dropdown", style: "position: relative; margin-right: 0.5rem;",
|
||||
button {
|
||||
class: "btn btn-primary btn-sm",
|
||||
r#type: "button",
|
||||
title: "Quick Create",
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": "{quick_create_open()}",
|
||||
onclick: move |_| quick_create_open.set(!quick_create_open()),
|
||||
onkeydown: move |evt: KeyboardEvent| {
|
||||
if evt.key() == Key::Escape {
|
||||
quick_create_open.set(false);
|
||||
}
|
||||
},
|
||||
"➕ New ▾"
|
||||
}
|
||||
if quick_create_open() {
|
||||
div {
|
||||
class: "dropdown-backdrop",
|
||||
style: "position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999; background: transparent;",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
}
|
||||
div {
|
||||
class: "dropdown-menu",
|
||||
role: "menu",
|
||||
style: "display: block; position: absolute; right: 0; top: 100%; z-index: 1000;",
|
||||
onkeydown: move |evt: KeyboardEvent| {
|
||||
if evt.key() == Key::Escape {
|
||||
quick_create_open.set(false);
|
||||
}
|
||||
},
|
||||
if can_create_user {
|
||||
Link {
|
||||
class: "dropdown-item",
|
||||
to: "/users?create=1",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
"👤 Create User"
|
||||
}
|
||||
}
|
||||
if can_create_tenant {
|
||||
Link {
|
||||
class: "dropdown-item",
|
||||
to: "/tenants?create=1",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
"🏢 Create Tenant"
|
||||
}
|
||||
}
|
||||
if can_create_app {
|
||||
Link {
|
||||
class: "dropdown-item",
|
||||
to: "/applications?create=1",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
"🚀 Create Application"
|
||||
}
|
||||
}
|
||||
if can_create_role {
|
||||
Link {
|
||||
class: "dropdown-item",
|
||||
to: "/roles?create=1",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
"🛡️ Create Role"
|
||||
}
|
||||
}
|
||||
if can_create_sa {
|
||||
Link {
|
||||
class: "dropdown-item",
|
||||
to: "/service-accounts?create=1",
|
||||
onclick: move |_| quick_create_open.set(false),
|
||||
"🤖 Create Service Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global Search (Ctrl+K)
|
||||
div { class: "search", style: "flex: 1; max-width: 400px; margin: 0 2rem;",
|
||||
div { style: "position: relative;",
|
||||
span { style: "position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); opacity: 0.5;", "🔍" }
|
||||
input {
|
||||
class: "form-control",
|
||||
style: "padding-left: 2rem; width: 100%;",
|
||||
r#type: "search",
|
||||
placeholder: "Search… (Ctrl+K)",
|
||||
"aria-label": "Global search",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "header-actions",
|
||||
// Quick Create
|
||||
button {
|
||||
class: "btn btn-primary btn-sm",
|
||||
style: "margin-right: 0.5rem;",
|
||||
r#type: "button",
|
||||
title: "Quick Create",
|
||||
"➕ New"
|
||||
}
|
||||
|
||||
// Notifications
|
||||
button {
|
||||
class: "btn btn-ghost btn-icon",
|
||||
r#type: "button",
|
||||
title: "Notifications",
|
||||
"aria-label": "Notifications",
|
||||
"🔔"
|
||||
}
|
||||
|
||||
// Theme
|
||||
button {
|
||||
@@ -161,8 +205,6 @@ pub fn Header() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[component]
|
||||
pub fn Sidebar() -> Element {
|
||||
let state = use_context::<AppState>();
|
||||
@@ -178,8 +220,7 @@ pub fn Sidebar() -> Element {
|
||||
};
|
||||
|
||||
let active = |r: &Route| -> bool {
|
||||
format!("{path:?}").split_whitespace().next()
|
||||
== format!("{r:?}").split_whitespace().next()
|
||||
format!("{path:?}").split_whitespace().next() == format!("{r:?}").split_whitespace().next()
|
||||
};
|
||||
|
||||
rsx! {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::routes::Route;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NavigationItem {
|
||||
pub id: String,
|
||||
|
||||
@@ -14,10 +14,10 @@ pub fn DataTable(
|
||||
on_search: EventHandler<String>,
|
||||
search_value: String,
|
||||
search_placeholder: String,
|
||||
|
||||
|
||||
on_sort: EventHandler<String>,
|
||||
sort_key: String,
|
||||
|
||||
|
||||
on_page: EventHandler<usize>,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
@@ -25,7 +25,7 @@ pub fn DataTable(
|
||||
|
||||
// Row Actions or other toolbar slots
|
||||
#[props(default)] toolbar_actions: Option<Element>,
|
||||
|
||||
|
||||
// The actual table body and header will be rendered internally
|
||||
// We expect the caller to just give us the table rows
|
||||
children: Element,
|
||||
@@ -40,11 +40,11 @@ pub fn DataTable(
|
||||
oninput: move |v| on_search.call(v),
|
||||
placeholder: "{search_placeholder}",
|
||||
}
|
||||
|
||||
|
||||
div { class: "spacer" }
|
||||
|
||||
|
||||
{toolbar_actions}
|
||||
|
||||
|
||||
// Column Visibility
|
||||
div { class: "dropdown",
|
||||
button {
|
||||
@@ -60,7 +60,6 @@ pub fn DataTable(
|
||||
input {
|
||||
r#type: "checkbox",
|
||||
checked: col.visible,
|
||||
// TODO: emit event
|
||||
}
|
||||
span { "{col.label}" }
|
||||
}
|
||||
@@ -68,16 +67,8 @@ pub fn DataTable(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CSV Export (future ready)
|
||||
button {
|
||||
class: "btn btn-outline",
|
||||
r#type: "button",
|
||||
title: "Export to CSV (Coming Soon)",
|
||||
"⬇ Export"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
div { class: "table-wrap",
|
||||
table { class: "data-table",
|
||||
thead {
|
||||
@@ -107,7 +98,7 @@ pub fn DataTable(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
crate::components::tables::Pagination {
|
||||
page: page,
|
||||
page_size: page_size,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Table helpers: search toolbar + pagination.
|
||||
pub mod datatable;
|
||||
pub use datatable::{DataTable, ColumnDef};
|
||||
pub use datatable::{ColumnDef, DataTable};
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
|
||||
@@ -65,11 +65,7 @@ pub fn Card(
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn StatCard(
|
||||
label: String,
|
||||
value: String,
|
||||
#[props(default)] hint: String,
|
||||
) -> Element {
|
||||
pub fn StatCard(label: String, value: String, #[props(default)] hint: String) -> Element {
|
||||
rsx! {
|
||||
div { class: "stat-card",
|
||||
div { class: "label", "{label}" }
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ mod utils;
|
||||
fn main() {
|
||||
// Surface panics in the browser console instead of a silent blank page.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
// Clear any pre-rendered loading banner in index.html (boot.js)
|
||||
|
||||
// Clear any pre-rendered loading banner in index.html (boot.js)
|
||||
// before Dioxus takes over `#main` and appends its root elements.
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(doc) = window.document() {
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct TenantsResponse {
|
||||
pub struct UserView {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub tenant_id: Option<String>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub last_login_at: Option<String>,
|
||||
@@ -124,8 +126,12 @@ pub struct ApplicationView {
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub credentials_configured: bool,
|
||||
#[serde(default)]
|
||||
pub redirect_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
@@ -141,6 +147,71 @@ pub struct ApplicationsResponse {
|
||||
pub applications: Vec<ApplicationView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct CreateApplicationResponse {
|
||||
pub application: ApplicationView,
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct RotateSecretResponse {
|
||||
pub client_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ApplicationMemberView {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub user_status: String,
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ApplicationMembersResponse {
|
||||
#[serde(default)]
|
||||
pub members: Vec<ApplicationMemberView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct UserApplicationMembershipView {
|
||||
pub id: String,
|
||||
pub application_id: String,
|
||||
pub user_id: String,
|
||||
pub role: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub application_name: String,
|
||||
#[serde(default)]
|
||||
pub application_slug: String,
|
||||
#[serde(default)]
|
||||
pub application_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub client_id: String,
|
||||
#[serde(default)]
|
||||
pub credentials_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct UserApplicationsResponse {
|
||||
#[serde(default)]
|
||||
pub applications: Vec<UserApplicationMembershipView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ServiceAccountView {
|
||||
pub id: String,
|
||||
|
||||
+1323
-38
File diff suppressed because it is too large.
Load diff
+116
-26
@@ -69,13 +69,17 @@ pub fn AuditPage() -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { load.call(()); });
|
||||
use_effect(move || {
|
||||
load.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
("Dashboard".to_string(), Some(Route::DashboardPage {})),
|
||||
("Audit Log".to_string(), None),
|
||||
]}
|
||||
Breadcrumb {
|
||||
items: vec![
|
||||
("Dashboard".to_string(), Some(Route::DashboardPage {})),
|
||||
("Audit Log".to_string(), None),
|
||||
],
|
||||
}
|
||||
|
||||
div { class: "page-header",
|
||||
div {
|
||||
@@ -84,12 +88,81 @@ pub fn AuditPage() -> Element {
|
||||
}
|
||||
div { class: "row",
|
||||
button {
|
||||
class: "btn btn-outline", r#type: "button",
|
||||
title: "Export is a placeholder",
|
||||
onclick: move |_| {},
|
||||
"Export (soon)"
|
||||
class: "btn btn-outline",
|
||||
r#type: "button",
|
||||
title: "Export filtered audit log records as CSV",
|
||||
onclick: move |_| {
|
||||
let mut parts = vec![
|
||||
"limit=5000".to_string(),
|
||||
"offset=0".to_string(),
|
||||
];
|
||||
|
||||
if !query().is_empty() {
|
||||
parts.push(format!("q={}", urlencoding_lite(&query())));
|
||||
}
|
||||
if !action().is_empty() {
|
||||
parts.push(format!("action={}", urlencoding_lite(&action())));
|
||||
}
|
||||
if !resource().is_empty() {
|
||||
parts.push(format!(
|
||||
"resource_type={}",
|
||||
urlencoding_lite(&resource())
|
||||
));
|
||||
}
|
||||
if severity() != "all" {
|
||||
parts.push(format!("severity={}", severity()));
|
||||
}
|
||||
if success() == "true" {
|
||||
parts.push("success=true".to_string());
|
||||
} else if success() == "false" {
|
||||
parts.push("success=false".to_string());
|
||||
}
|
||||
if !since().is_empty() {
|
||||
parts.push(format!("since={}", urlencoding_lite(&since())));
|
||||
}
|
||||
if !until().is_empty() {
|
||||
parts.push(format!("until={}", urlencoding_lite(&until())));
|
||||
}
|
||||
|
||||
let qs = parts.join("&");
|
||||
let export_url = format!("/api/v1/audit/export?{qs}");
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
if let Some(window) = web_sys::window() {
|
||||
if let Some(document) = window.document() {
|
||||
if let Ok(element) = document.create_element("a") {
|
||||
let _ = element.set_attribute("href", &export_url);
|
||||
let _ = element.set_attribute(
|
||||
"download",
|
||||
"audit_export.csv",
|
||||
);
|
||||
|
||||
if let Ok(html_element) =
|
||||
element.dyn_into::<web_sys::HtmlElement>()
|
||||
{
|
||||
html_element.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let _ = export_url;
|
||||
}
|
||||
},
|
||||
"Export CSV"
|
||||
}
|
||||
button {
|
||||
class: "btn btn-outline",
|
||||
r#type: "button",
|
||||
onclick: move |_| load.call(()),
|
||||
"Refresh"
|
||||
}
|
||||
button { class: "btn btn-outline", r#type: "button", onclick: move |_| load.call(()), "Refresh" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,19 +175,22 @@ pub fn AuditPage() -> Element {
|
||||
placeholder: "Search action, resource, IP…",
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;max-width:140px;",
|
||||
class: "form-control",
|
||||
style: "width:auto;max-width:140px;",
|
||||
placeholder: "Action",
|
||||
value: "{action()}",
|
||||
oninput: move |e| action.set(e.value()),
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;max-width:140px;",
|
||||
class: "form-control",
|
||||
style: "width:auto;max-width:140px;",
|
||||
placeholder: "Resource",
|
||||
value: "{resource()}",
|
||||
oninput: move |e| resource.set(e.value()),
|
||||
}
|
||||
select {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
value: "{severity()}",
|
||||
onchange: move |e| severity.set(e.value()),
|
||||
option { value: "all", "All severities" }
|
||||
@@ -123,7 +199,8 @@ pub fn AuditPage() -> Element {
|
||||
option { value: "critical", "Critical" }
|
||||
}
|
||||
select {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
value: "{success()}",
|
||||
onchange: move |e| success.set(e.value()),
|
||||
option { value: "all", "Success/Fail" }
|
||||
@@ -131,22 +208,28 @@ pub fn AuditPage() -> Element {
|
||||
option { value: "false", "Failure" }
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
r#type: "date",
|
||||
value: "{since()}",
|
||||
oninput: move |e| since.set(e.value()),
|
||||
title: "Since",
|
||||
}
|
||||
input {
|
||||
class: "form-control", style: "width:auto;",
|
||||
class: "form-control",
|
||||
style: "width:auto;",
|
||||
r#type: "date",
|
||||
value: "{until()}",
|
||||
oninput: move |e| until.set(e.value()),
|
||||
title: "Until",
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary", r#type: "button",
|
||||
onclick: move |_| { page.set(0); load.call(()); },
|
||||
class: "btn btn-primary",
|
||||
r#type: "button",
|
||||
onclick: move |_| {
|
||||
page.set(0);
|
||||
load.call(());
|
||||
},
|
||||
"Apply"
|
||||
}
|
||||
}
|
||||
@@ -182,17 +265,23 @@ pub fn AuditPage() -> Element {
|
||||
for e in d.entries {
|
||||
tr { key: "{e.id}",
|
||||
td { class: "mono", "{format_datetime(&e.created_at)}" }
|
||||
td { code { "{e.action}" } }
|
||||
td {
|
||||
code { "{e.action}" }
|
||||
}
|
||||
td {
|
||||
span { "{e.resource_type}" }
|
||||
if let Some(rid) = &e.resource_id {
|
||||
div { class: "mono text-muted", style: "font-size:11px;",
|
||||
div {
|
||||
class: "mono text-muted",
|
||||
style: "font-size:11px;",
|
||||
"{rid}"
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
span { class: "{severity_badge_class(&e.severity)}", "{e.severity}" }
|
||||
span { class: "{severity_badge_class(&e.severity)}",
|
||||
"{e.severity}"
|
||||
}
|
||||
}
|
||||
td {
|
||||
if e.success {
|
||||
@@ -204,9 +293,7 @@ pub fn AuditPage() -> Element {
|
||||
td { class: "mono",
|
||||
"{e.actor_user_id.as_deref().unwrap_or(\"—\")}"
|
||||
}
|
||||
td { class: "mono",
|
||||
"{e.ip_address.as_deref().unwrap_or(\"—\")}"
|
||||
}
|
||||
td { class: "mono", "{e.ip_address.as_deref().unwrap_or(\"—\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,9 +301,12 @@ pub fn AuditPage() -> Element {
|
||||
}
|
||||
Pagination {
|
||||
page: page(),
|
||||
page_size: page_size,
|
||||
page_size,
|
||||
total: d.total as usize,
|
||||
on_page: move |p| { page.set(p); load.call(()); },
|
||||
on_page: move |p| {
|
||||
page.set(p);
|
||||
load.call(());
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ pub fn LoginPage() -> Element {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tenant_id: user_val
|
||||
.get("tenant_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
status: user_val
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -125,7 +129,9 @@ pub fn LoginPage() -> Element {
|
||||
nav.replace(Route::DashboardPage {});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = web_sys::console::warn_1(&format!("[nx9-auth-ui] Login failed: {e:?}").into());
|
||||
let _ = web_sys::console::warn_1(
|
||||
&format!("[nx9-auth-ui] Login failed: {e:?}").into(),
|
||||
);
|
||||
// Map API errors to a safe, non-enumerating message for creds.
|
||||
let msg = match e {
|
||||
api::ApiError::Unauthorized
|
||||
|
||||
@@ -32,7 +32,9 @@ pub fn DashboardPage() -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { load.call(()); });
|
||||
use_effect(move || {
|
||||
load.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![("Dashboard".to_string(), None)] }
|
||||
@@ -243,12 +245,6 @@ fn AdminSummary(admin: Value) -> Element {
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let health = admin
|
||||
.get("system_health")
|
||||
.and_then(|v| v.get("status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
rsx! {
|
||||
div { class: "mb-2",
|
||||
h2 { style: "margin-bottom: 0.75rem;", "Administrator overview" }
|
||||
@@ -303,16 +299,6 @@ fn AdminSummary(admin: Value) -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card mt-2",
|
||||
div { class: "card-body row", style: "justify-content:space-between;",
|
||||
span {
|
||||
strong { "System health: " }
|
||||
span { class: "badge badge-success", "{health}" }
|
||||
}
|
||||
span { class: "text-muted", "Placeholder probe — expand in a future release" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-10
@@ -1,7 +1,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::TextInput;
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::{GroupView, UserView};
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -30,27 +30,45 @@ pub fn GroupsPage() -> Element {
|
||||
error.set(None);
|
||||
spawn(async move {
|
||||
match api::list_groups().await {
|
||||
Ok(list) => { groups.set(list); loading.set(false); }
|
||||
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
|
||||
Ok(list) => {
|
||||
groups.set(list);
|
||||
loading.set(false);
|
||||
}
|
||||
Err(e) => {
|
||||
error.set(Some(e.to_string()));
|
||||
loading.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let mut filtered: Vec<GroupView> = groups()
|
||||
.into_iter()
|
||||
.filter(|g| matches_query(&g.name, &query()) || g.description.as_deref().map(|d| matches_query(d, &query())).unwrap_or(false))
|
||||
.filter(|g| {
|
||||
matches_query(&g.name, &query())
|
||||
|| g.description
|
||||
.as_deref()
|
||||
.map(|d| matches_query(d, &query()))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
let sk = sort_key();
|
||||
filtered.sort_by(|a, b| match sk.as_str() {
|
||||
"members" => b.member_count.cmp(&a.member_count),
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
|
||||
let total = filtered.len();
|
||||
let page_items: Vec<GroupView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
let page_items: Vec<GroupView> = filtered
|
||||
.into_iter()
|
||||
.skip(page() * page_size)
|
||||
.take(page_size)
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -207,7 +225,7 @@ pub fn GroupDetailPage(id: String) -> Element {
|
||||
let mut all_users = use_signal(Vec::<UserView>::new);
|
||||
let mut error = use_signal(|| Option::<String>::None);
|
||||
let mut loading = use_signal(|| true);
|
||||
|
||||
|
||||
let mut add_user_id = use_signal(String::new);
|
||||
|
||||
let mut edit_mode = use_signal(|| false);
|
||||
@@ -237,7 +255,9 @@ pub fn GroupDetailPage(id: String) -> Element {
|
||||
});
|
||||
});
|
||||
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::components::feedback::{EmptyState, ErrorState, LoadingSpinner};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::PermissionsResponse;
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -35,7 +35,9 @@ pub fn PermissionsPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -60,7 +62,7 @@ pub fn PermissionsPage() -> Element {
|
||||
let groups: Vec<String> = d.groups.iter().map(|g| g.group.clone()).collect();
|
||||
let q = query();
|
||||
let gf = group_filter();
|
||||
|
||||
|
||||
// Flatten permissions for data table
|
||||
let mut all_perms: Vec<(String, crate::models::PermissionView)> = Vec::new();
|
||||
for g in &d.groups {
|
||||
@@ -72,14 +74,14 @@ pub fn PermissionsPage() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let sk = sort_key();
|
||||
all_perms.sort_by(|a, b| match sk.as_str() {
|
||||
"group" => a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name)),
|
||||
"description" => a.1.description.cmp(&b.1.description),
|
||||
_ => a.1.name.cmp(&b.1.name),
|
||||
});
|
||||
|
||||
|
||||
let total = all_perms.len();
|
||||
let page_items: Vec<_> = all_perms.into_iter().skip(page() * page_size).take(page_size).collect();
|
||||
|
||||
|
||||
@@ -40,7 +40,9 @@ pub fn ProfilePage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Breadcrumb { items: vec![
|
||||
@@ -177,7 +179,7 @@ pub fn ProfilePage() -> Element {
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
div { class: "card-header", h3 { "Coming soon" } }
|
||||
div { class: "card-header", h3 { "Planned security features" } }
|
||||
div { class: "card-body stack",
|
||||
div { class: "row", style: "justify-content:space-between;",
|
||||
span { "Avatar upload" }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
|
||||
use crate::components::forms::{Checkbox, TextInput};
|
||||
use crate::components::navigation::Breadcrumb;
|
||||
use crate::components::tables::{DataTable, ColumnDef};
|
||||
use crate::components::tables::{ColumnDef, DataTable};
|
||||
use crate::models::{PermissionView, RoleView};
|
||||
use crate::routes::Route;
|
||||
use crate::services::api;
|
||||
@@ -52,7 +52,17 @@ pub fn RolesPage() -> Element {
|
||||
}
|
||||
});
|
||||
});
|
||||
use_effect(move || { reload.call(()); });
|
||||
use_effect(move || {
|
||||
reload.call(());
|
||||
});
|
||||
|
||||
let can_create =
|
||||
state.auth.read().has_permission("roles:manage") || state.auth.read().is_adminish();
|
||||
use_effect(move || {
|
||||
if can_create && crate::utils::check_and_clear_create_intent() {
|
||||
show_create.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
let mut filtered: Vec<RoleView> = roles()
|
||||
.into_iter()
|
||||
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user