12 Commits
Author SHA1 Message Date
thakares 526c4aa157 feat: introduce application membership model and credential hardening 2026-08-07 19:00:09 +05:30
thakares 3d14061795 refactor: harden audit filtering and management UI 2026-07-24 17:36:14 +05:30
thakares a969f9c571 feat: complete NX9-Auth management and integrity hardening 2026-07-24 16:18:48 +05:30
thakares dc5417334b feat: harden runtime lifecycle and application credentials
- enforce deterministic runtime lifecycle state transitions
- add live graceful-to-forced shutdown escalation
- align HTTP draining and worker shutdown with global deadline
- guarantee deterministic shutdown hook ordering
- add secure application client IDs and one-time client secrets
- hash application secrets with BLAKE3 and constant-time verification
- make credential creation and rotation transactionally auditable
- enforce strict client_id authentication and redirect URI validation
- add SQLite and PostgreSQL credential migrations
- add application credential and runtime lifecycle acceptance tests
- update Dioxus application management workflows
- update security and architecture documentation
2026-07-23 15:17:30 +05:30
thakares 4c697e9adf docs: expand runtime lifecycle architecture documentation 2026-07-23 13:15:26 +05:30
thakares b6798e7a7c ci: improve GitHub Actions workflow diagnostics 2026-07-22 21:16:48 +05:30
thakares 36903d2125 docs: update architecture and fix GitHub Actions workflow 2026-07-22 21:05:20 +05:30
thakares 0134ff6a50 docs: add architecture documentation and standardize filenames 2026-07-22 20:18:38 +05:30
thakares 0010f2cfb8 docs: add architecture documentation and standardize filenames 2026-07-22 20:15:22 +05:30
thakares 5c1340c9cb docs: add architecture documentation and standardize filenames 2026-07-22 20:08:46 +05:30
thakares af68b43ae0 Missing: License files, updated 2026-07-22 19:58:16 +05:30
thakares 112ce77891 docs: remove internal recovery report 2026-07-22 19:52:07 +05:30
98 changed files with 12719 additions and 993 deletions

No files matched your search

+81 -11
View File
@@ -1,22 +1,92 @@
- uses: actions/checkout@v4
name: Rust CI
- name: Install Rust
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: full
jobs:
test:
name: Rust CI
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
rust:
- stable
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
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt --check
- name: Environment Information
run: |
echo "=== Git ==="
git rev-parse HEAD
git log --oneline -1
git status
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
echo
echo "=== Rust ==="
rustc --version
cargo --version
- name: Tests
run: cargo test --all
echo
echo "=== System ==="
uname -a
- name: Release build
run: cargo build --release
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
+59 -66
View File
@@ -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",
@@ -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",
+2 -1
View File
@@ -2,7 +2,6 @@
name = "nx9-auth"
version = "0.3.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
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+137 -16
View File
@@ -4,7 +4,7 @@
**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*
[![Version](https://img.shields.io/badge/version-v0.3.0-blue.svg)]()
[![Rust](https://img.shields.io/badge/Rust-2024-orange.svg)](https://www.rust-lang.org/)
@@ -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.
+370
View File
@@ -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.
-82
View File
@@ -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
View File
@@ -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.
+446
View File
@@ -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.
+912
View File
@@ -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>
-63
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+31 -1
View File
@@ -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))
+162
View File
@@ -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 {
if !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 {
if !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 })))
}
+10 -10
View File
@@ -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
View File
@@ -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 })))
}
@@ -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);
+42
View File
@@ -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()
}
}
+58
View File
@@ -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)
}
}
+2
View File
@@ -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,
}
+10
View File
@@ -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,
}
+4
View File
@@ -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;
+30
View File
@@ -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(())
}
}
+191 -26
View File
@@ -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)
@@ -66,35 +120,146 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
Ok(())
}
async fn update(
&self,
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
async fn update_secret_hash(&self, id: &str, secret_hash: &str) -> 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
"#,
"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(name)
.bind(slug)
.bind(enabled)
.bind(secret_hash)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM applications WHERE id = $1")
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(())
}
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> {
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(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+50 -77
View File
@@ -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,41 +47,17 @@ 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
"#,
)
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)
@@ -106,39 +65,53 @@ impl AuditRepository for PostgresAuditRepository {
}
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 '\'
)
"#,
)
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())
}
+2
View File
@@ -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;
+68 -7
View File
@@ -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,15 +71,49 @@ 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);
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
@@ -74,11 +122,18 @@ impl TenantsRepository for PostgresTenantsRepository {
"#,
)
.bind(name)
.bind(slug)
.bind(&target_slug)
.bind(id)
.execute(&self.pool)
.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(())
}
}
+108
View File
@@ -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(())
}
}
+194 -30
View File
@@ -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)
@@ -66,35 +120,145 @@ impl ApplicationsRepository for SqliteApplicationsRepository {
Ok(())
}
async fn update(
&self,
id: &str,
name: &str,
slug: &str,
enabled: bool,
) -> Result<(), sqlx::Error> {
async fn update_secret_hash(&self, id: &str, secret_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
UPDATE applications
SET name = ?, slug = ?, enabled = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?
"#,
"UPDATE applications SET client_secret_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
)
.bind(name)
.bind(slug)
.bind(enabled)
.bind(secret_hash)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM applications WHERE id = ?")
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(())
}
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> {
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(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
+71 -90
View File
@@ -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 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 '\'
)
"#,
)
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)
}
}
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())
.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')))
"#;
+68
View File
@@ -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())
}
+2
View File
@@ -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;
+68 -7
View File
@@ -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,15 +71,49 @@ 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);
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
@@ -74,11 +122,18 @@ impl TenantsRepository for SqliteTenantsRepository {
"#,
)
.bind(name)
.bind(slug)
.bind(&target_slug)
.bind(id)
.execute(&self.pool)
.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(())
}
}
+117
View File
@@ -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
View File
@@ -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>;
}
+331
View File
@@ -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 {
if 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)
}
+376 -16
View File
@@ -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,176 @@ 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)?
{
if other.id != id {
if other.entity_id != id || other.entity_type != "application" {
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(())
}
+2
View File
@@ -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;
+133
View File
@@ -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());
}
}
+59 -36
View File
@@ -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(());
}
self.state.force_set(RuntimeState::Draining);
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);
}
println!("Draining");
if self.state.load() == RuntimeState::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;
}
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);
println!("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);
println!("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;
}
let _ = self
.state
.transition(RuntimeState::ClosingResources, RuntimeState::Stopped);
println!("Stopped");
tracing::info!("application stopped cleanly");
}
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 => {
@@ -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
}
+47 -7
View File
@@ -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;
}
}
+6 -1
View File
@@ -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];
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+23 -60
View File
@@ -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,14 @@ 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 +69,9 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
}
let normalized = password.to_lowercase();
let weak_list = [
// Use a HashSet for O(1) exact matching against compromised/weak passwords
let weak_passwords: HashSet<&str> = [
"password",
"admin123",
"qwerty",
@@ -88,56 +80,27 @@ pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(),
"administrator",
"nx9-auth",
"nx9auth",
];
"password123",
"admin",
"letmein",
"welcome",
]
.iter()
.copied()
.collect();
for weak in &weak_list {
if normalized.contains(weak) {
if weak_passwords.contains(normalized.as_str()) {
return Err(AppError::InvalidInput(
"password contains a weak or common sequence".to_string(),
"password is too common or weak".to_string(),
));
}
// Additional check: reject if it's a simple sequence or pattern
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());
}
}
+14 -60
View File
@@ -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.map_or(true, |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,11 +70,8 @@ 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(s) = self.state.get(&ip) {
if let Some(until) = s.locked_until {
if Instant::now() < until {
return Err(AppError::RateLimited);
@@ -83,8 +82,6 @@ impl RateLimiter {
}
/// 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();
@@ -127,6 +124,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 +143,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)
);
}
}
+722
View File
@@ -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;
}
+194
View File
@@ -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;
}
+278
View File
@@ -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;
}
+34
View File
@@ -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
);
}
+230 -3
View File
@@ -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(())
}
+335
View File
@@ -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);
}
+570
View File
@@ -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
View File
@@ -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",
+3 -6
View File
@@ -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 {
+89 -48
View File
@@ -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,65 +49,96 @@ 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()),
// 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: "margin-left: 0.4rem; font-weight: 500;",
span { style: "font-weight: 500; font-size: 13px;",
{(state.tenant)().map(|t| t.name).unwrap_or("Default Tenant".to_string())}
}
span { style: "margin-left: 0.25rem; opacity: 0.6;", "▾" }
}
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",
class: "btn btn-xs btn-ghost text-primary",
to: Route::TenantsPage {},
onclick: move |_| tenant_menu_open.set(false),
"Manage tenants…"
}
}
}
}
// 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 { style: "flex: 1;" }
div { class: "header-actions",
// Quick Create
if has_any_create {
div { class: "dropdown", style: "position: relative; margin-right: 0.5rem;",
button {
class: "btn btn-primary btn-sm",
style: "margin-right: 0.5rem;",
r#type: "button",
title: "Quick Create",
"➕ New"
"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"
}
}
}
}
}
// Notifications
button {
class: "btn btn-ghost btn-icon",
r#type: "button",
title: "Notifications",
"aria-label": "Notifications",
"🔔"
}
// Theme
@@ -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
View File
@@ -1,6 +1,5 @@
use crate::routes::Route;
#[derive(Clone, Debug, PartialEq)]
pub struct NavigationItem {
pub id: String,
-9
View File
@@ -60,7 +60,6 @@ pub fn DataTable(
input {
r#type: "checkbox",
checked: col.visible,
// TODO: emit event
}
span { "{col.label}" }
}
@@ -68,14 +67,6 @@ 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",
+1 -1
View File
@@ -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::*;
+1 -5
View File
@@ -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}" }
+71
View File
@@ -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,
File diff suppressed because it is too large. Load diff
+114 -24
View File
@@ -69,13 +69,17 @@ pub fn AuditPage() -> Element {
});
});
use_effect(move || { load.call(()); });
use_effect(move || {
load.call(());
});
rsx! {
Breadcrumb { items: vec![
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(());
},
}
}
}
+7 -1
View File
@@ -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
+3 -17
View File
@@ -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" }
}
}
}
}
}
+27 -7
View File
@@ -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,17 +30,31 @@ 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();
@@ -50,7 +64,11 @@ pub fn GroupsPage() -> Element {
});
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![
@@ -237,7 +255,9 @@ pub fn GroupDetailPage(id: String) -> Element {
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
rsx! {
Breadcrumb { items: vec![
+4 -2
View File
@@ -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![
+4 -2
View File
@@ -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" }
+12 -2
View File
@@ -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()
+17 -3
View File
@@ -3,7 +3,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::components::widgets::StatusChip;
use crate::models::ServiceAccountView;
use crate::routes::Route;
@@ -44,7 +44,17 @@ pub fn ServiceAccountsPage() -> Element {
}
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let can_create = state.auth.read().has_permission("service_accounts: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<_> = items()
.into_iter()
@@ -64,7 +74,11 @@ pub fn ServiceAccountsPage() -> Element {
});
let total = filtered.len();
let page_items: Vec<_> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
let page_items: Vec<_> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! {
Breadcrumb { items: vec![
+25 -10
View File
@@ -1,6 +1,6 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner};
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef};
use crate::components::tables::{ColumnDef, DataTable};
use crate::models::SessionView;
use crate::routes::Route;
use crate::services::api;
@@ -9,12 +9,19 @@ use crate::utils::{format_datetime, matches_query};
use dioxus::prelude::*;
fn parse_browser(ua: &str) -> String {
if ua.contains("Chrome") && !ua.contains("Edg") { "Chrome".to_string() }
else if ua.contains("Firefox") { "Firefox".to_string() }
else if ua.contains("Safari") && !ua.contains("Chrome") { "Safari".to_string() }
else if ua.contains("Edg") { "Edge".to_string() }
else if ua.is_empty() { "Unknown".to_string() }
else { ua.chars().take(30).collect::<String>() + "..." }
if ua.contains("Chrome") && !ua.contains("Edg") {
"Chrome".to_string()
} else if ua.contains("Firefox") {
"Firefox".to_string()
} else if ua.contains("Safari") && !ua.contains("Chrome") {
"Safari".to_string()
} else if ua.contains("Edg") {
"Edge".to_string()
} else if ua.is_empty() {
"Unknown".to_string()
} else {
ua.chars().take(30).collect::<String>() + "..."
}
}
#[component]
@@ -32,13 +39,21 @@ pub fn SessionsPage() -> Element {
error.set(None);
spawn(async move {
match api::list_sessions().await {
Ok(r) => { sessions.set(r.sessions); loading.set(false); }
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
Ok(r) => {
sessions.set(r.sessions);
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 filtered: Vec<SessionView> = sessions()
.into_iter()
+1 -1
View File
@@ -1,8 +1,8 @@
use crate::components::navigation::Breadcrumb;
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
use crate::theme::ThemeMode;
use crate::services::api;
use dioxus::prelude::*;
#[component]
+426 -14
View File
@@ -1,8 +1,8 @@
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::models::TenantView;
use crate::components::tables::{ColumnDef, DataTable};
use crate::models::{ApplicationView, AuditEntry, TenantView, UserView};
use crate::routes::Route;
use crate::services::api;
use crate::state::{AppState, ToastKind};
@@ -30,13 +30,29 @@ pub fn TenantsPage() -> Element {
error.set(None);
spawn(async move {
match api::list_tenants().await {
Ok(list) => { tenants.set(list); loading.set(false); }
Err(e) => { error.set(Some(e.to_string())); loading.set(false); }
Ok(list) => {
tenants.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 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 filtered = {
let q = query();
@@ -52,7 +68,11 @@ pub fn TenantsPage() -> Element {
list
};
let total = filtered.len();
let page_items: Vec<TenantView> = filtered.into_iter().skip(page() * page_size).take(page_size).collect();
let page_items: Vec<TenantView> = filtered
.into_iter()
.skip(page() * page_size)
.take(page_size)
.collect();
rsx! {
Breadcrumb { items: vec![
@@ -227,21 +247,44 @@ pub fn TenantDetailPage(id: String) -> Element {
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut tab = use_signal(|| "overview".to_string());
let mut edit_name = use_signal(String::new);
let mut edit_slug = use_signal(String::new);
let mut tenant_users = use_signal(Vec::<UserView>::new);
let mut all_users = use_signal(Vec::<UserView>::new);
let mut tenant_apps = use_signal(Vec::<ApplicationView>::new);
let mut activity = use_signal(Vec::<AuditEntry>::new);
let mut user_query = use_signal(String::new);
let mut show_assign_modal = use_signal(|| false);
let mut selected_assign_user_id = use_signal(String::new);
let mut confirm_reassign_user = use_signal(|| Option::<(UserView, String, String)>::None);
let mut confirm_move_default = use_signal(|| Option::<UserView>::None);
let mut confirm_delete = use_signal(|| false);
let tenant_id = id.clone();
let reload = use_callback(move |_: ()| {
let id = tenant_id.clone();
loading.set(true);
error.set(None);
spawn(async move {
match api::get_tenant(&id).await {
Ok(t) => {
edit_name.set(t.name.clone());
edit_slug.set(t.slug.clone());
tenant.set(Some(t));
if let Ok(users) = api::list_tenant_users(&id).await {
tenant_users.set(users);
}
if let Ok(users) = api::list_users().await {
all_users.set(users);
}
if let Ok(apps) = api::list_tenant_applications(&id).await {
tenant_apps.set(apps);
}
loading.set(false);
}
Err(e) => {
@@ -252,7 +295,20 @@ pub fn TenantDetailPage(id: String) -> Element {
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let tenant_id_act = id.clone();
let load_activity = use_callback(move |_: ()| {
let id = tenant_id_act.clone();
spawn(async move {
let q = format!("resource_type=tenant&resource_id={id}&limit=50");
if let Ok(resp) = api::list_audit(&q).await {
activity.set(resp.entries);
}
});
});
rsx! {
Breadcrumb { items: vec![
@@ -267,17 +323,71 @@ pub fn TenantDetailPage(id: String) -> Element {
ErrorState { message: err, on_retry: move |_| reload.call(()) }
} else if let Some(t) = tenant() {
{
let tid = t.id.clone();
let tid2 = t.id.clone();
let tid3 = t.id.clone();
let tid_save = t.id.clone();
let tid_assign = t.id.clone();
let tid_move_default = t.id.clone();
let tid_delete = t.id.clone();
let tenant_name = t.name.clone();
let is_default_tenant = t.id == "00000000-0000-0000-0000-000000000001";
let current_member_ids: Vec<String> = tenant_users().iter().map(|u| u.id.clone()).collect();
let assignable_users: Vec<UserView> = all_users()
.into_iter()
.filter(|u| !current_member_ids.contains(&u.id))
.collect();
let filtered_members: Vec<UserView> = {
let q = user_query();
tenant_users()
.into_iter()
.filter(|u| matches_query(&u.username, &q))
.collect()
};
rsx! {
div { class: "page-header",
div {
h1 { "{t.name}" }
p { class: "desc", "Tenant configuration and overview" }
p { class: "desc",
code { "{t.slug}" }
" · Tenant ID: "
code { "{t.id}" }
}
}
}
div { class: "tabs", style: "display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap;",
button {
class: if tab() == "overview" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("overview".into()),
"Overview"
}
button {
class: if tab() == "users" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("users".into()),
"Users ({tenant_users().len()})"
}
button {
class: if tab() == "applications" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| tab.set("applications".into()),
"Applications ({tenant_apps().len()})"
}
button {
class: if tab() == "activity" { "btn btn-sm btn-primary" } else { "btn btn-sm btn-outline" },
r#type: "button",
onclick: move |_| {
tab.set("activity".into());
load_activity.call(());
},
"Activity"
}
}
// ── Tab: Overview ──────────────────────────────────────────
if tab() == "overview" {
div { class: "grid-2",
div { class: "card",
div { class: "card-header", h3 { "Tenant Details" } }
@@ -292,13 +402,16 @@ pub fn TenantDetailPage(id: String) -> Element {
value: edit_slug(),
oninput: move |v| edit_slug.set(v),
}
p { class: "desc text-muted", style: "font-size:12px; margin-top:-0.5rem;",
"Leaving slug blank derives it automatically from name. Changing a tenant slug may affect existing references."
}
button {
class: "btn btn-primary mt-2",
r#type: "button",
onclick: move |_| {
let n = edit_name();
let s = edit_slug();
let tid = tid.clone();
let tid = tid_save.clone();
spawn(async move {
match api::update_tenant(&tid, &n, Some(s.as_str()).filter(|s| !s.is_empty())).await {
Ok(_) => {
@@ -321,14 +434,313 @@ pub fn TenantDetailPage(id: String) -> Element {
button {
class: "btn btn-danger",
r#type: "button",
disabled: tid2 == "00000000-0000-0000-0000-000000000001",
disabled: is_default_tenant,
onclick: move |_| confirm_delete.set(true),
"Delete Tenant"
}
}
}
}
}
// ── Tab: Users (Tenant User Assignment) ───────────────────
if tab() == "users" {
div { class: "card",
div { class: "card-header", style: "display:flex; justify-content:space-between; align-items:center;",
div {
h3 { "Tenant User Assignment" }
p { class: "desc", "Users assigned to this tenant owner. Every user has exactly one tenant owner." }
}
div { class: "row", style: "gap:0.5rem;",
button {
class: "btn btn-primary btn-sm",
r#type: "button",
onclick: move |_| {
selected_assign_user_id.set(String::new());
show_assign_modal.set(true);
},
"+ Assign User"
}
Link {
class: "btn btn-outline btn-sm",
to: Route::UsersPage {},
"+ Create User"
}
}
}
div { class: "card-body",
DataTable {
columns: vec![
ColumnDef { key: "username".into(), label: "Username".into(), sortable: true, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: true, visible: true },
ColumnDef { key: "created_at".into(), label: "Created".into(), sortable: true, visible: true },
ColumnDef { key: "actions".into(), label: "Actions".into(), sortable: false, visible: true },
],
on_search: move |v| user_query.set(v),
search_value: user_query(),
search_placeholder: "Filter tenant users…".to_string(),
on_sort: move |_| {},
sort_key: "username".to_string(),
on_page: move |_| {},
page: 0,
page_size: 100,
total: filtered_members.len(),
toolbar_actions: rsx! {
button { class: "btn btn-outline btn-sm", r#type: "button", onclick: move |_| reload.call(()), "Refresh" }
},
for u in filtered_members {
{
let u_clone = u.clone();
rsx! {
tr { key: "{u.id}",
td {
Link {
to: Route::UserDetailPage { id: u.id.clone() },
strong { "{u.username}" }
}
div { class: "mono text-muted", style: "font-size:11px;", "{u.id}" }
}
td {
span { class: "badge badge-success", "{u.status}" }
}
td { "{u.created_at}" }
td { style: "text-align: right;",
if !is_default_tenant {
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| confirm_move_default.set(Some(u_clone.clone())),
"Move to Default Tenant"
}
} else {
span { class: "text-muted", style: "font-size:12px;", "Default Tenant Owner" }
}
}
}
}
}
}
}
}
}
}
// ── Tab: Applications ─────────────────────────────────────
if tab() == "applications" {
div { class: "card",
div { class: "card-header",
h3 { "Tenant Applications" }
p { class: "desc", "Applications associated with this tenant." }
}
div { class: "card-body",
if tenant_apps().is_empty() {
EmptyState {
title: "No applications found".to_string(),
description: "No applications are currently associated with this tenant.".to_string(),
icon: "🚀".to_string(),
}
} else {
table { class: "table",
thead {
tr {
th { "Application" }
th { "Slug" }
th { "Client ID" }
th { "Status" }
th { style: "text-align: right;", "Actions" }
}
}
tbody {
for app in tenant_apps() {
tr { key: "{app.id}",
td {
Link {
to: Route::ApplicationDetailPage { id: app.id.clone() },
strong { "{app.name}" }
}
}
td { code { "{app.slug}" } }
td { code { "{app.client_id}" } }
td {
span {
class: if app.enabled { "badge badge-success" } else { "badge badge-secondary" },
if app.enabled { "Active" } else { "Disabled" }
}
}
td { style: "text-align: right;",
Link {
class: "btn btn-sm btn-outline",
to: Route::ApplicationDetailPage { id: app.id.clone() },
"View"
}
}
}
}
}
}
}
}
}
}
// ── Tab: Activity ──────────────────────────────────────────
if tab() == "activity" {
div { class: "card",
div { class: "card-header",
h3 { "Tenant Audit Log" }
p { class: "desc", "Audit events scoped to this tenant." }
}
div { class: "card-body",
if activity().is_empty() {
EmptyState {
title: "No activity recorded".to_string(),
description: "No audit events found for this tenant.".to_string(),
icon: "📜".to_string(),
}
} else {
table { class: "table",
thead {
tr {
th { "Timestamp" }
th { "Action" }
th { "Actor" }
th { "Severity" }
th { "IP Address" }
}
}
tbody {
for act in activity() {
tr { key: "{act.id}",
td { "{act.created_at}" }
td { strong { "{act.action}" } }
td { "{act.actor_user_id.as_deref().unwrap_or(\"—\")}" }
td {
span { class: "badge badge-info", "{act.severity}" }
}
td { "{act.ip_address.as_deref().unwrap_or(\"—\")}" }
}
}
}
}
}
}
}
}
// ── Assign User Modal ──────────────────────────────────────
Modal {
title: "Assign User to Tenant".to_string(),
open: show_assign_modal(),
on_close: move |_| show_assign_modal.set(false),
p { class: "desc", "Select an existing NX9-Auth user to reassign to tenant \"{tenant_name}\"." }
div { class: "form-group", style: "margin-top:1rem;",
label { class: "form-label", "Select User" }
select {
class: "form-control",
value: selected_assign_user_id(),
onchange: move |evt: Event<FormData>| selected_assign_user_id.set(evt.value()),
option { value: "", "— Select an existing user —" }
for u in assignable_users.clone() {
option {
value: "{u.id}",
"{u.username} (currently in tenant: {u.tenant_id.as_deref().unwrap_or(\"default\")})"
}
}
}
}
div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button", onclick: move |_| show_assign_modal.set(false), "Cancel" }
button {
class: "btn btn-primary",
r#type: "button",
disabled: selected_assign_user_id().is_empty(),
onclick: move |_| {
let uid = selected_assign_user_id();
if let Some(target_u) = assignable_users.iter().find(|u| u.id == uid) {
let from = target_u.tenant_id.clone().unwrap_or_else(|| "default".to_string());
confirm_reassign_user.set(Some((target_u.clone(), from, tid_assign.clone())));
show_assign_modal.set(false);
}
},
"Assign User"
}
}
}
// ── Confirm Reassign User Dialog ─────────────────────────
if let Some((target_u, from_tenant, to_tenant_id)) = confirm_reassign_user() {
{
let u_id = target_u.id.clone();
let u_name = target_u.username.clone();
let to_tid = to_tenant_id.clone();
let dest_name = tenant_name.clone();
rsx! {
ConfirmDialog {
title: "Confirm Tenant Reassignment".to_string(),
message: format!(
"Reassign user \"{}\" from tenant \"{}\" to \"{}\"?",
u_name, from_tenant, dest_name
),
open: true,
confirm_label: "Reassign User".to_string(),
danger: false,
on_confirm: move |_| {
let uid = u_id.clone();
let tid = to_tid.clone();
confirm_reassign_user.set(None);
spawn(async move {
match api::assign_tenant_user(&tid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "User reassigned to tenant");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| confirm_reassign_user.set(None),
}
}
}
}
// ── Confirm Move to Default Tenant Dialog ────────────────
if let Some(target_u) = confirm_move_default() {
{
let u_id = target_u.id.clone();
let u_name = target_u.username.clone();
let tid_curr = tid_move_default.clone();
rsx! {
ConfirmDialog {
title: "Move to Default Tenant".to_string(),
message: format!(
"Reassign user \"{}\" from tenant \"{}\" to Default Tenant?",
u_name, tenant_name
),
open: true,
confirm_label: "Move to Default Tenant".to_string(),
danger: false,
on_confirm: move |_| {
let uid = u_id.clone();
let tid = tid_curr.clone();
confirm_move_default.set(None);
spawn(async move {
match api::remove_tenant_user(&tid, &uid).await {
Ok(_) => {
state.toast(ToastKind::Success, "User reassigned to Default Tenant");
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
},
on_cancel: move |_| confirm_move_default.set(None),
}
}
}
}
// ── Confirm Delete Tenant Dialog ─────────────────────────
ConfirmDialog {
title: "Delete tenant".to_string(),
message: format!("Delete tenant \"{}\"? This cannot be undone.", t.name),
@@ -336,7 +748,7 @@ pub fn TenantDetailPage(id: String) -> Element {
confirm_label: "Delete",
danger: true,
on_confirm: move |_| {
let tid = tid3.clone();
let tid = tid_delete.clone();
spawn(async move {
match api::delete_tenant(&tid).await {
Ok(_) => {
+3 -1
View File
@@ -41,7 +41,9 @@ pub fn TokensPage() -> Element {
}
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let filtered: Vec<TokenView> = tokens()
.into_iter()
+76 -3
View File
@@ -3,7 +3,7 @@
use crate::components::feedback::{ConfirmDialog, EmptyState, ErrorState, LoadingSpinner, Modal};
use crate::components::forms::{PasswordInput, TextInput};
use crate::components::navigation::Breadcrumb;
use crate::components::tables::{DataTable, ColumnDef};
use crate::components::tables::{ColumnDef, DataTable};
use crate::components::widgets::StatusChip;
use crate::models::UserView;
use crate::routes::Route;
@@ -47,7 +47,17 @@ pub fn UsersPage() -> Element {
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
let can_create =
state.auth.read().has_permission("users:create") || state.auth.read().is_adminish();
use_effect(move || {
if can_create && crate::utils::check_and_clear_create_intent() {
show_create.set(true);
}
});
let filtered = {
let q = query();
@@ -305,9 +315,13 @@ pub fn UsersPage() -> Element {
#[component]
pub fn UserDetailPage(id: String) -> Element {
let state = use_context::<AppState>();
let state_auth = state.auth;
let can_manage_apps = state_auth().has_permission("applications:manage");
let mut user = use_signal(|| Option::<UserView>::None);
let mut roles = use_signal(Vec::<crate::models::RoleView>::new);
let mut all_roles = use_signal(Vec::<crate::models::RoleView>::new);
let mut user_apps = use_signal(Vec::<crate::models::UserApplicationMembershipView>::new);
let mut error = use_signal(|| Option::<String>::None);
let mut loading = use_signal(|| true);
let mut new_pass = use_signal(String::new);
@@ -326,6 +340,11 @@ pub fn UserDetailPage(id: String) -> Element {
if let Ok(ar) = api::list_roles().await {
all_roles.set(ar);
}
if let Ok(apps) = api::list_user_applications(&id).await {
user_apps.set(apps);
} else {
user_apps.set(Vec::new());
}
loading.set(false);
}
Err(e) => {
@@ -335,7 +354,9 @@ pub fn UserDetailPage(id: String) -> Element {
}
});
});
use_effect(move || { reload.call(()); });
use_effect(move || {
reload.call(());
});
rsx! {
Breadcrumb { items: vec![
@@ -485,6 +506,58 @@ pub fn UserDetailPage(id: String) -> Element {
}
}
}
if can_manage_apps {
div { class: "card", style: "grid-column: 1 / -1;",
div { class: "card-header", h3 { "Applications" } }
div { class: "card-body",
p { class: "text-secondary", style: "font-size:0.9rem; margin-bottom:0.75rem;",
"Applications this user is assigned to. Membership roles are metadata only and do not change global RBAC."
}
if user_apps().is_empty() {
p { class: "text-muted", "Not assigned to any applications." }
} else {
DataTable {
columns: vec![
ColumnDef { key: "name".into(), label: "Application".into(), sortable: false, visible: true },
ColumnDef { key: "role".into(), label: "Membership Role".into(), sortable: false, visible: true },
ColumnDef { key: "status".into(), label: "Status".into(), sortable: false, visible: true },
ColumnDef { key: "assigned".into(), label: "Assigned".into(), sortable: false, visible: true },
],
on_search: |_| {},
search_value: "".to_string(),
search_placeholder: "".to_string(),
on_sort: |_| {},
sort_key: "".to_string(),
on_page: |_| {},
page: 0,
page_size: user_apps().len().max(1),
total: user_apps().len(),
for m in user_apps() {
tr { key: "{m.id}",
td {
Link {
to: Route::ApplicationDetailPage { id: m.application_id.clone() },
strong { "{m.application_name}" }
}
div { class: "text-muted", style: "font-size:0.8rem;",
code { "{m.application_slug}" }
}
}
td { span { class: "badge badge-accent", "{m.role}" } }
td {
StatusChip {
status: if m.enabled { "active".to_string() } else { "disabled".to_string() }
}
}
td { "{format_datetime(&m.created_at)}" }
}
}
}
}
}
}
}
}
}
}
+6 -3
View File
@@ -3,11 +3,11 @@
use crate::components::layout::AppLayout;
use crate::pages::{
about::AboutPage,
applications::ApplicationsPage,
applications::{ApplicationDetailPage, ApplicationsPage},
audit::AuditPage,
auth::{ForbiddenPage, LoginPage, UnauthorizedPage},
dashboard::DashboardPage,
groups::{GroupsPage, GroupDetailPage},
groups::{GroupDetailPage, GroupsPage},
not_found::NotFoundPage,
permissions::PermissionsPage,
profile::ProfilePage,
@@ -15,7 +15,7 @@ use crate::pages::{
service_accounts::ServiceAccountsPage,
sessions::SessionsPage,
settings::SettingsPage,
tenants::{TenantsPage, TenantDetailPage},
tenants::{TenantDetailPage, TenantsPage},
tokens::TokensPage,
users::{UserDetailPage, UsersPage},
};
@@ -77,6 +77,9 @@ pub enum Route {
#[route("/applications")]
ApplicationsPage {},
#[route("/applications/:id")]
ApplicationDetailPage { id: String },
#[route("/service-accounts")]
ServiceAccountsPage {},
+154 -13
View File
@@ -59,9 +59,25 @@ fn client() -> Client {
Client::new()
}
pub trait RequestBuilderExtHelper {
fn with_credentials_include(self) -> Self;
}
impl RequestBuilderExtHelper for reqwest::RequestBuilder {
#[cfg(target_arch = "wasm32")]
fn with_credentials_include(self) -> Self {
self.fetch_credentials_include()
}
#[cfg(not(target_arch = "wasm32"))]
fn with_credentials_include(self) -> Self {
self
}
}
/// Attach credentials + optional bearer session token.
fn authorize(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let builder = builder.fetch_credentials_include();
let builder = builder.with_credentials_include();
if let Some(token) = session::load_access_token() {
builder.header("Authorization", format!("Bearer {token}"))
} else {
@@ -180,7 +196,7 @@ pub async fn login(username: &str, password: &str) -> Result<LoginResponse, ApiE
let url = api_url("/auth/login");
let resp = client()
.post(&url)
.fetch_credentials_include()
.with_credentials_include()
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(&body)
@@ -236,7 +252,10 @@ pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
Ok(r.tenants)
}
pub async fn update_profile(email: Option<&str>, full_name: Option<&str>) -> Result<Value, ApiError> {
pub async fn update_profile(
email: Option<&str>,
full_name: Option<&str>,
) -> Result<Value, ApiError> {
let body = serde_json::json!({ "email": email, "full_name": full_name });
patch_json("/profile", &body).await
}
@@ -369,30 +388,113 @@ pub async fn list_applications() -> Result<Vec<ApplicationView>, ApiError> {
Ok(r.applications)
}
pub async fn create_application(name: &str, slug: &str) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = post_json("/applications", &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
pub async fn create_application(
name: &str,
slug: &str,
description: Option<&str>,
redirect_urls: Option<Vec<String>>,
scopes: Option<Vec<String>>,
) -> Result<CreateApplicationResponse, ApiError> {
let body = serde_json::json!({
"name": name,
"slug": slug,
"description": description,
"redirect_urls": redirect_urls,
"scopes": scopes,
});
post_json("/applications", &body).await
}
pub async fn update_application(
id: &str,
name: &str,
slug: &str,
description: Option<&str>,
redirect_urls: Option<Vec<String>>,
scopes: Option<Vec<String>>,
enabled: bool,
) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug, "enabled": enabled });
let body = serde_json::json!({
"name": name,
"slug": slug,
"description": description,
"redirect_urls": redirect_urls,
"scopes": scopes,
"enabled": enabled,
});
let r: Value = patch_json(&format!("/applications/{id}"), &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
let r: RotateSecretResponse = post_json(
&format!("/applications/{id}/secret"),
&serde_json::json!({}),
)
.await?;
Ok(r.client_secret)
}
pub async fn delete_application(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/applications/{id}")).await?;
Ok(())
}
pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
let r: Value = get(&format!("/applications/{id}")).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn list_application_members(
app_id: &str,
) -> Result<Vec<ApplicationMemberView>, ApiError> {
let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?;
Ok(r.members)
}
pub async fn add_application_member(
app_id: &str,
user_id: &str,
role: Option<&str>,
) -> Result<ApplicationMemberView, ApiError> {
let body = serde_json::json!({
"user_id": user_id,
"role": role,
});
let r: Value = post_json(&format!("/applications/{app_id}/members"), &body).await?;
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn update_application_member(
app_id: &str,
user_id: &str,
role: Option<&str>,
enabled: Option<bool>,
) -> Result<ApplicationMemberView, ApiError> {
let body = serde_json::json!({
"role": role,
"enabled": enabled,
});
let r: Value = patch_json(&format!("/applications/{app_id}/members/{user_id}"), &body).await?;
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn remove_application_member(app_id: &str, user_id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/applications/{app_id}/members/{user_id}")).await?;
Ok(())
}
pub async fn list_user_applications(
user_id: &str,
) -> Result<Vec<UserApplicationMembershipView>, ApiError> {
let r: UserApplicationsResponse = get(&format!("/users/{user_id}/applications")).await?;
Ok(r.applications)
}
// ── Service accounts ──────────────────────────────────────────────────────────
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
@@ -422,8 +524,11 @@ pub async fn delete_service_account(id: &str) -> Result<(), ApiError> {
}
pub async fn rotate_service_account_secret(id: &str) -> Result<String, ApiError> {
let r: Value =
post_json(&format!("/service-accounts/{id}/secret"), &serde_json::json!({})).await?;
let r: Value = post_json(
&format!("/service-accounts/{id}/secret"),
&serde_json::json!({}),
)
.await?;
Ok(r.get("raw_secret")
.and_then(|v| v.as_str())
.unwrap_or("")
@@ -475,7 +580,11 @@ pub async fn get_group(id: &str) -> Result<GroupDetailResponse, ApiError> {
get(&format!("/groups/{id}")).await
}
pub async fn update_group(id: &str, name: &str, description: Option<&str>) -> Result<GroupView, ApiError> {
pub async fn update_group(
id: &str,
name: &str,
description: Option<&str>,
) -> Result<GroupView, ApiError> {
let body = serde_json::json!({ "name": name, "description": description });
let r: Value = patch_json(&format!("/groups/{id}"), &body).await?;
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null))
@@ -507,7 +616,11 @@ pub async fn create_tenant(name: &str, slug: Option<&str>) -> Result<TenantView,
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn update_tenant(id: &str, name: &str, slug: Option<&str>) -> Result<TenantView, ApiError> {
pub async fn update_tenant(
id: &str,
name: &str,
slug: Option<&str>,
) -> Result<TenantView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?;
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
@@ -524,3 +637,31 @@ pub async fn delete_tenant(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
Ok(())
}
pub async fn list_tenant_users(tenant_id: &str) -> Result<Vec<UserView>, ApiError> {
let r: Value = get(&format!("/tenants/{tenant_id}/users")).await?;
serde_json::from_value(r.get("users").cloned().unwrap_or(Value::Array(vec![])))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn assign_tenant_user(tenant_id: &str, user_id: &str) -> Result<UserView, ApiError> {
let body = serde_json::json!({ "user_id": user_id });
let r: Value = post_json(&format!("/tenants/{tenant_id}/users"), &body).await?;
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/tenants/{tenant_id}/users/{user_id}")).await?;
Ok(())
}
pub async fn list_tenant_applications(tenant_id: &str) -> Result<Vec<ApplicationView>, ApiError> {
let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?;
serde_json::from_value(
r.get("applications")
.cloned()
.unwrap_or(Value::Array(vec![])),
)
.map_err(|e| ApiError::Other(e.to_string()))
}
-2
View File
@@ -33,5 +33,3 @@ pub fn clear() {
SessionStorage::delete(ACCESS_KEY);
SessionStorage::delete(REFRESH_KEY);
}
+7 -8
View File
@@ -1,9 +1,9 @@
//! Global application state via Dioxus signals / context.
use crate::models::MeResponse;
use crate::theme::{self, ThemeMode};
use crate::routes::Route;
use crate::components::navigation::registry::{NavigationItem, NavigationRegistry};
use crate::models::MeResponse;
use crate::routes::Route;
use crate::theme::{self, ThemeMode};
use dioxus::prelude::*;
/// Toast notification.
@@ -134,7 +134,7 @@ impl AppState {
permission: None,
children: vec![],
},
]
],
);
registry.register_section(
"Security",
@@ -179,7 +179,7 @@ impl AppState {
permission: Some("roles:manage".into()),
children: vec![],
},
]
],
);
registry.register_section(
"Audit & Logs",
@@ -200,7 +200,7 @@ impl AppState {
permission: Some("audit:view".into()),
children: vec![],
},
]
],
);
registry.register_section(
"System",
@@ -221,7 +221,7 @@ impl AppState {
permission: None,
children: vec![],
},
]
],
);
let state = Self {
@@ -276,4 +276,3 @@ impl AppState {
self.set_theme(next);
}
}
+52 -1
View File
@@ -2,7 +2,10 @@
/// Initials from a username (up to 2 chars).
pub fn initials(name: &str) -> String {
let parts: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|s| !s.is_empty()).collect();
let parts: Vec<&str> = name
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.collect();
if parts.is_empty() {
return "?".to_string();
}
@@ -75,3 +78,51 @@ pub fn slugify(s: &str) -> String {
.collect::<Vec<_>>()
.join("-")
}
/// Check if location search contains exact `create=1` query parameter, and clear `create=1` from history URL while preserving other parameters.
pub fn check_and_clear_create_intent() -> bool {
#[cfg(target_arch = "wasm32")]
{
if let Some(window) = web_sys::window() {
if let Ok(search) = window.location().search() {
let query_str = search.trim_start_matches('?');
let mut has_create = false;
let mut remaining_params = Vec::new();
for part in query_str.split('&') {
if part.is_empty() {
continue;
}
let mut key_val = part.splitn(2, '=');
let key = key_val.next().unwrap_or("");
let val = key_val.next().unwrap_or("");
if key == "create" && val == "1" {
has_create = true;
} else {
remaining_params.push(part);
}
}
if has_create {
if let Ok(pathname) = window.location().pathname() {
let new_search = if remaining_params.is_empty() {
String::new()
} else {
format!("?{}", remaining_params.join("&"))
};
let new_url = format!("{pathname}{new_search}");
let _ = window.history().and_then(|h| {
h.replace_state_with_url(
&wasm_bindgen::JsValue::NULL,
"",
Some(&new_url),
)
});
}
return true;
}
}
}
}
false
}