Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
@@ -0,0 +1,41 @@
|
|||||||
|
name: Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
rust:
|
||||||
|
- stable
|
||||||
|
- beta
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: ${{ matrix.rust }}
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --all
|
||||||
|
|
||||||
|
- name: Release build
|
||||||
|
run: cargo build --release
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/target
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.env
|
||||||
|
config.toml
|
||||||
|
```gitignore
|
||||||
|
# Rust
|
||||||
|
/target
|
||||||
|
|
||||||
|
# SQLite
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
tarpaulin-report.html
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local configs
|
||||||
|
config.toml
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Temporary backups
|
||||||
|
backups/
|
||||||
|
scratch/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
```
|
||||||
|
.idea/
|
||||||
Generated
+2517
File diff suppressed because it is too large.
Load diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
[package]
|
||||||
|
name = "nx9-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
authors = ["NX9 Team","Sunil Thakare"]
|
||||||
|
description = "Lightweight self-hosted IAM service for the NX9 ecosystem"
|
||||||
|
license = "Apache-2.0 or MIT -- Dual License"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nx9-auth"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "nx9_auth"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# HTTP framework
|
||||||
|
axum = { version = "0.8.9", features = ["macros"] }
|
||||||
|
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||||
|
tower = { version = "0.5", features = ["full"] }
|
||||||
|
tower-http = { version = "0.6.11", features = ["trace", "request-id", "compression-gzip", "cors", "set-header"] }
|
||||||
|
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite", "chrono", "macros"] }
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
|
||||||
|
# Token/session hashing
|
||||||
|
blake3 = "1.8.5"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4.6.1", features = ["derive", "color", "env"] }
|
||||||
|
|
||||||
|
# Serialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Time
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.23.3", features = ["v4"] }
|
||||||
|
time = { version = "0.3", features = ["macros"] }
|
||||||
|
|
||||||
|
# Config
|
||||||
|
toml = "0.8"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "fmt"] }
|
||||||
|
|
||||||
|
# Random
|
||||||
|
rand = { version = "0.8", features = ["std", "std_rng"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
thiserror = "2.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
# Rate limiter
|
||||||
|
dashmap = "6.0"
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
opt-level = 0
|
||||||
|
debug = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# --- Stage 1: Build the binary ---
|
||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/nx9-auth
|
||||||
|
|
||||||
|
# 1. Pre-build dependencies for caching
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
# Create dummy main.rs, lib.rs, and src/bin/bench.rs to compile dependencies first
|
||||||
|
RUN mkdir -p src/bin src/security src/identity src/db src/api src/audit src/config src/middleware src/error && \
|
||||||
|
echo "fn main() {}" > src/main.rs && \
|
||||||
|
echo "fn main() {}" > src/bin/bench.rs && \
|
||||||
|
echo "" > src/lib.rs && \
|
||||||
|
cargo build --release && \
|
||||||
|
rm -rf src/
|
||||||
|
|
||||||
|
# 2. Copy the actual source files and build
|
||||||
|
COPY . .
|
||||||
|
# Touch main.rs, lib.rs and src/bin/bench.rs to force cargo to rebuild them with the actual contents
|
||||||
|
RUN touch src/main.rs src/lib.rs src/bin/bench.rs && \
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# --- Stage 2: Run the binary ---
|
||||||
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
|
# Install CA certificates, curl (for healthcheck), and SQLite CLI
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl sqlite3 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create a non-root group and user
|
||||||
|
RUN groupadd -g 10001 nx9-auth && \
|
||||||
|
useradd -u 10001 -g nx9-auth -m -s /usr/sbin/nologin nx9-auth
|
||||||
|
|
||||||
|
# Create standard system directories (system mode)
|
||||||
|
RUN mkdir -p /etc/nx9-auth /var/lib/nx9-auth /var/log/nx9-auth /var/backups/nx9-auth && \
|
||||||
|
chown -R nx9-auth:nx9-auth /etc/nx9-auth /var/lib/nx9-auth /var/log/nx9-auth /var/backups/nx9-auth
|
||||||
|
|
||||||
|
# Copy the compiled release binary from builder
|
||||||
|
COPY --from=builder /usr/src/nx9-auth/target/release/nx9-auth /usr/local/bin/nx9-auth
|
||||||
|
|
||||||
|
# Switch to the non-root user
|
||||||
|
USER nx9-auth
|
||||||
|
|
||||||
|
# Set standard environment variables
|
||||||
|
ENV NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml
|
||||||
|
|
||||||
|
# Expose server port
|
||||||
|
EXPOSE 8655
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/usr/local/bin/nx9-auth"]
|
||||||
|
CMD ["serve"]
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# nx9-auth
|
||||||
|
|
||||||
|
A lightweight Identity and Access Management (IAM) service for the NX9 ecosystem.
|
||||||
|
|
||||||
|
Built with Rust, Axum, SQLite, and modern security practices, `nx9-auth` provides authentication, authorization, session management, personal access tokens, audit logging, and role-based access control in a single deployable binary.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
* User management
|
||||||
|
* Role-Based Access Control (RBAC)
|
||||||
|
* Session authentication
|
||||||
|
* Personal Access Tokens (PAT)
|
||||||
|
* Audit logging
|
||||||
|
* Transaction-safe operations
|
||||||
|
* SQLite with WAL mode
|
||||||
|
* Online backups
|
||||||
|
* Interactive initialization
|
||||||
|
* Docker and CasaOS support
|
||||||
|
* Systemd deployment support
|
||||||
|
* XDG-compliant user mode
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Initialize a new installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx9-auth init
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx9-auth serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8655/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx9-auth init
|
||||||
|
nx9-auth serve
|
||||||
|
nx9-auth doctor
|
||||||
|
|
||||||
|
nx9-auth create-user
|
||||||
|
nx9-auth create-admin
|
||||||
|
|
||||||
|
nx9-auth create-token
|
||||||
|
nx9-auth revoke-token
|
||||||
|
|
||||||
|
nx9-auth show-user
|
||||||
|
nx9-auth show-token
|
||||||
|
|
||||||
|
nx9-auth backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Modes
|
||||||
|
|
||||||
|
### User Mode
|
||||||
|
|
||||||
|
Uses XDG directories:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.config/nx9-auth/
|
||||||
|
~/.local/share/nx9-auth/
|
||||||
|
~/.local/state/nx9-auth/
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Mode
|
||||||
|
|
||||||
|
```text
|
||||||
|
/etc/nx9-auth/
|
||||||
|
/var/lib/nx9-auth/
|
||||||
|
/var/log/nx9-auth/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### CasaOS
|
||||||
|
|
||||||
|
```text
|
||||||
|
/DATA/AppData/nx9-auth
|
||||||
|
├── config
|
||||||
|
├── db
|
||||||
|
├── state
|
||||||
|
└── backups
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
* Argon2id password hashing
|
||||||
|
* BLAKE3 token hashing
|
||||||
|
* Session revocation
|
||||||
|
* Transactional audit logging
|
||||||
|
* Timing attack mitigation
|
||||||
|
* Security regression test suite
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test --all
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test coverage includes:
|
||||||
|
|
||||||
|
* Unit tests
|
||||||
|
* Integration tests
|
||||||
|
* Security tests
|
||||||
|
* Migration compatibility tests
|
||||||
|
* CLI tests
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v0.1.x
|
||||||
|
|
||||||
|
* Stable IAM core
|
||||||
|
* BZOD integration
|
||||||
|
|
||||||
|
### v0.2.x
|
||||||
|
|
||||||
|
* OAuth2 Authorization Server
|
||||||
|
* OpenID Connect (OIDC)
|
||||||
|
* PKCE support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0 or MIT -- Dual License
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Git commit hash
|
||||||
|
let git_commit = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Build timestamp (UTC)
|
||||||
|
let build_date = Command::new("date")
|
||||||
|
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Rust version
|
||||||
|
let rust_version = Command::new("rustc")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=GIT_COMMIT={git_commit}");
|
||||||
|
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||||
|
println!("cargo:rustc-env=RUST_VERSION={rust_version}");
|
||||||
|
|
||||||
|
// Re-run if git HEAD changes
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/refs/heads");
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: nx9-auth
|
||||||
|
services:
|
||||||
|
nx9-auth:
|
||||||
|
image: nx9-auth:0.1.0
|
||||||
|
container_name: nx9-auth
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8655:8655"
|
||||||
|
volumes:
|
||||||
|
- /DATA/AppData/nx9-auth/config:/etc/nx9-auth
|
||||||
|
- /DATA/AppData/nx9-auth/db:/var/lib/nx9-auth
|
||||||
|
- /DATA/AppData/nx9-auth/state:/var/log/nx9-auth
|
||||||
|
- /DATA/AppData/nx9-auth/backups:/var/backups/nx9-auth
|
||||||
|
environment:
|
||||||
|
- NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8655/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
x-casaos:
|
||||||
|
envs:
|
||||||
|
- name: NX9_AUTH_CONFIG
|
||||||
|
description: "Path to configuration file inside container"
|
||||||
|
value: "/etc/nx9-auth/config.toml"
|
||||||
|
ports:
|
||||||
|
- container: "8655"
|
||||||
|
description: "Internal port for the IAM service API"
|
||||||
|
volumes:
|
||||||
|
- container: "/etc/nx9-auth"
|
||||||
|
description: "Directory containing config.toml"
|
||||||
|
- container: "/var/lib/nx9-auth"
|
||||||
|
description: "Directory containing auth.db"
|
||||||
|
- container: "/var/log/nx9-auth"
|
||||||
|
description: "Directory containing log and session state files"
|
||||||
|
- container: "/var/backups/nx9-auth"
|
||||||
|
description: "Directory containing database backups"
|
||||||
|
|
||||||
|
x-casaos:
|
||||||
|
architectures:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
main: nx9-auth
|
||||||
|
title:
|
||||||
|
en_us: NX9 Auth
|
||||||
|
icon: https://raw.githubusercontent.com/sunil-thakare/nx9-auth/main/icon.png
|
||||||
|
port_map: "8655"
|
||||||
|
scheme: http
|
||||||
|
index: /health
|
||||||
|
category: Utility
|
||||||
|
developer: NX9 Team
|
||||||
|
description:
|
||||||
|
en_us: Lightweight self-hosted Identity & Access Management (IAM) service for the NX9 ecosystem, featuring SQLite WAL databases, RBAC authorization, sessions, and PAT tokens.
|
||||||
|
tips:
|
||||||
|
before_install:
|
||||||
|
en_us: "After installing, please initialize the database and administrator account by running: docker exec -it nx9-auth nx9-auth init"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# nx9-auth Configuration Reference
|
||||||
|
# Copy this file to /etc/nx9-auth/config.toml and adjust for your environment.
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Interface to bind on. Use 127.0.0.1 if running behind a reverse proxy.
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
# Port the service listens on.
|
||||||
|
port = 8655
|
||||||
|
|
||||||
|
[database]
|
||||||
|
# Absolute path to the SQLite database file.
|
||||||
|
# The directory must be writable by the nx9-auth user.
|
||||||
|
path = "/var/lib/nx9-auth/auth.db"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
# Session idle timeout in hours. Sessions unused for longer than this are expired.
|
||||||
|
session_ttl_hours = 24
|
||||||
|
|
||||||
|
# Session absolute lifetime in days. Sessions older than this are always expired,
|
||||||
|
# regardless of activity.
|
||||||
|
session_absolute_ttl_days = 30
|
||||||
|
|
||||||
|
# Default API token lifetime in days (365 = 1 year).
|
||||||
|
token_ttl_days = 365
|
||||||
|
|
||||||
|
# Argon2id memory cost in KiB. Higher = more secure but slower.
|
||||||
|
# Minimum recommended: 65536 (64 MiB)
|
||||||
|
argon2_memory = 65536
|
||||||
|
|
||||||
|
# Argon2id iteration count. Higher = more secure but slower.
|
||||||
|
argon2_iterations = 3
|
||||||
|
|
||||||
|
# Argon2id parallelism (number of threads).
|
||||||
|
argon2_parallelism = 1
|
||||||
|
|
||||||
|
[audit]
|
||||||
|
# Enable structured audit logging to the database.
|
||||||
|
# Disable only in development environments.
|
||||||
|
enabled = true
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh — nx9-auth installer for Debian/Ubuntu systems
|
||||||
|
#
|
||||||
|
# Usage: sudo bash deploy.sh [path/to/nx9-auth-binary]
|
||||||
|
# Requires: root, systemd
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY_PATH="${1:-./target/release/nx9-auth}"
|
||||||
|
SERVICE_USER="nx9-auth"
|
||||||
|
INSTALL_BIN="/usr/local/bin/nx9-auth"
|
||||||
|
CONFIG_DIR="/etc/nx9-auth"
|
||||||
|
DATA_DIR="/var/lib/nx9-auth"
|
||||||
|
LOG_DIR="/var/log/nx9-auth"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/nx9-auth.service"
|
||||||
|
|
||||||
|
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
ok() { echo -e "${GREEN} ✓${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW} !${NC} $*"; }
|
||||||
|
fail() { echo -e "${RED} ✗${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
# ── Prerequisites ─────────────────────────────────────────────────────────────
|
||||||
|
[[ $EUID -eq 0 ]] || fail "This script must be run as root."
|
||||||
|
[[ -f "$BINARY_PATH" ]] || fail "Binary not found at: $BINARY_PATH — build with 'cargo build --release' first."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " nx9-auth deploy"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Create system user ────────────────────────────────────────────────────────
|
||||||
|
if id -u "$SERVICE_USER" &>/dev/null; then
|
||||||
|
warn "System user '$SERVICE_USER' already exists — skipping creation."
|
||||||
|
else
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||||
|
ok "Created system user: $SERVICE_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Create directories ────────────────────────────────────────────────────────
|
||||||
|
for dir in "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"; do
|
||||||
|
mkdir -p "$dir"
|
||||||
|
chown "$SERVICE_USER:$SERVICE_USER" "$dir"
|
||||||
|
chmod 750 "$dir"
|
||||||
|
done
|
||||||
|
ok "Directories created: $CONFIG_DIR, $DATA_DIR, $LOG_DIR"
|
||||||
|
|
||||||
|
# ── Install binary ────────────────────────────────────────────────────────────
|
||||||
|
cp "$BINARY_PATH" "$INSTALL_BIN"
|
||||||
|
chmod 755 "$INSTALL_BIN"
|
||||||
|
ok "Binary installed: $INSTALL_BIN"
|
||||||
|
|
||||||
|
# ── Write default config if not present ──────────────────────────────────────
|
||||||
|
if [[ ! -f "$CONFIG_DIR/config.toml" ]]; then
|
||||||
|
cat > "$CONFIG_DIR/config.toml" <<'EOF'
|
||||||
|
[server]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8655
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "/var/lib/nx9-auth/auth.db"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
session_ttl_hours = 24
|
||||||
|
session_absolute_ttl_days = 30
|
||||||
|
token_ttl_days = 365
|
||||||
|
argon2_memory = 65536
|
||||||
|
argon2_iterations = 3
|
||||||
|
argon2_parallelism = 1
|
||||||
|
|
||||||
|
[audit]
|
||||||
|
enabled = true
|
||||||
|
EOF
|
||||||
|
chown root:"$SERVICE_USER" "$CONFIG_DIR/config.toml"
|
||||||
|
chmod 640 "$CONFIG_DIR/config.toml"
|
||||||
|
ok "Default config written: $CONFIG_DIR/config.toml"
|
||||||
|
else
|
||||||
|
warn "Config already exists — skipping: $CONFIG_DIR/config.toml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Install systemd service ───────────────────────────────────────────────────
|
||||||
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=nx9-auth Identity and Access Management Service
|
||||||
|
Documentation=https://github.com/nx9/nx9-auth
|
||||||
|
After=network.target
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=$SERVICE_USER
|
||||||
|
Group=$SERVICE_USER
|
||||||
|
ExecStart=$INSTALL_BIN serve --config $CONFIG_DIR/config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=10s
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
|
||||||
|
# Writable paths
|
||||||
|
ReadWritePaths=$DATA_DIR $LOG_DIR
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=nx9-auth
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 644 "$SERVICE_FILE"
|
||||||
|
ok "Systemd service installed: $SERVICE_FILE"
|
||||||
|
|
||||||
|
# ── Initialize database and configuration ─────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "Initializing database and configuration..."
|
||||||
|
sudo -u "$SERVICE_USER" "$INSTALL_BIN" init --config "$CONFIG_DIR/config.toml" --non-interactive --skip-admin
|
||||||
|
ok "Initialization complete"
|
||||||
|
|
||||||
|
# ── Enable and start service ──────────────────────────────────────────────────
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nx9-auth
|
||||||
|
systemctl restart nx9-auth
|
||||||
|
ok "nx9-auth service enabled and started"
|
||||||
|
|
||||||
|
# ── Doctor check ──────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
sleep 2 # Brief wait for service to start
|
||||||
|
sudo -u "$SERVICE_USER" "$INSTALL_BIN" doctor --config "$CONFIG_DIR/config.toml" || true
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo " nx9-auth deployed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo " Service: systemctl status nx9-auth"
|
||||||
|
echo " Logs: journalctl -u nx9-auth -f"
|
||||||
|
echo " Config: $CONFIG_DIR/config.toml"
|
||||||
|
echo " Database: $DATA_DIR/auth.db"
|
||||||
|
echo ""
|
||||||
|
echo " Next step:"
|
||||||
|
echo " Create your first administrator account:"
|
||||||
|
echo " sudo -u nx9-auth nx9-auth init --config $CONFIG_DIR/config.toml"
|
||||||
|
echo ""
|
||||||
|
echo " Then verify:"
|
||||||
|
echo " systemctl status nx9-auth"
|
||||||
|
echo " curl http://127.0.0.1:8655/health"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
nx9-auth:
|
||||||
|
build: .
|
||||||
|
container_name: nx9-auth
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8655:8655"
|
||||||
|
volumes:
|
||||||
|
- nx9-auth-config:/etc/nx9-auth
|
||||||
|
- nx9-auth-db:/var/lib/nx9-auth
|
||||||
|
- nx9-auth-state:/var/log/nx9-auth
|
||||||
|
- nx9-auth-backups:/var/backups/nx9-auth
|
||||||
|
environment:
|
||||||
|
- NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8655/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nx9-auth-config:
|
||||||
|
nx9-auth-db:
|
||||||
|
nx9-auth-state:
|
||||||
|
nx9-auth-backups:
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# nx9-auth Database Backups & Recovery
|
||||||
|
|
||||||
|
Since `nx9-auth` uses SQLite with Write-Ahead Logging (WAL) enabled, standard file copies of `auth.db` during high-concurrency operations can result in corrupted backups. This guide details the correct procedures for backing up and restoring the database safely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Online Backups (Recommended)
|
||||||
|
|
||||||
|
SQLite provides a built-in backup API that safely reads the database and locks it transactionally to capture a consistent snapshot, merging concurrent WAL journals correctly without interrupting the running service.
|
||||||
|
|
||||||
|
To perform an online backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create backups directory
|
||||||
|
mkdir -p /var/backups/nx9-auth
|
||||||
|
|
||||||
|
# Run SQLite .backup query
|
||||||
|
sqlite3 /var/lib/nx9-auth/auth.db ".backup /var/backups/nx9-auth/auth_$(date +%F_%H%M%S).db"
|
||||||
|
|
||||||
|
# Change ownership and permissions to protect secrets
|
||||||
|
chown root:root /var/backups/nx9-auth/auth_*.db
|
||||||
|
chmod 600 /var/backups/nx9-auth/auth_*.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automation via Cron
|
||||||
|
|
||||||
|
You can automate this daily by adding a cron job to `/etc/cron.daily/nx9-auth-backup`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
BACKUP_DIR="/var/backups/nx9-auth"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
sqlite3 /var/lib/nx9-auth/auth.db ".backup $BACKUP_DIR/auth_$(date +%F).db"
|
||||||
|
chmod 600 "$BACKUP_DIR"/auth_*.db
|
||||||
|
# Keep only last 30 days of backups
|
||||||
|
find "$BACKUP_DIR" -name "auth_*.db" -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure the cron script is executable:
|
||||||
|
```bash
|
||||||
|
chmod +x /etc/cron.daily/nx9-auth-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Offline Backups
|
||||||
|
|
||||||
|
If you need to copy the raw database file directly, you **must** stop the service first to ensure all transactions are fully written to the disk and the WAL log is empty:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop the service
|
||||||
|
sudo systemctl stop nx9-auth
|
||||||
|
|
||||||
|
# 2. Copy the database file
|
||||||
|
cp /var/lib/nx9-auth/auth.db /var/backups/nx9-auth/auth_offline_$(date +%F).db
|
||||||
|
|
||||||
|
# 3. Start the service
|
||||||
|
sudo systemctl start nx9-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database Recovery
|
||||||
|
|
||||||
|
To restore the database from a backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Stop the running service
|
||||||
|
sudo systemctl stop nx9-auth
|
||||||
|
|
||||||
|
# 2. Backup the current corrupted/old database just in case
|
||||||
|
mv /var/lib/nx9-auth/auth.db /var/lib/nx9-auth/auth.db.bak
|
||||||
|
|
||||||
|
# 3. Copy the backup file into place
|
||||||
|
cp /var/backups/nx9-auth/auth_2026-06-21.db /var/lib/nx9-auth/auth.db
|
||||||
|
|
||||||
|
# 4. Correct ownership and permissions
|
||||||
|
chown nx9-auth:nx9-auth /var/lib/nx9-auth/auth.db
|
||||||
|
chmod 640 /var/lib/nx9-auth/auth.db
|
||||||
|
|
||||||
|
# 5. Start the service
|
||||||
|
sudo systemctl start nx9-auth
|
||||||
|
|
||||||
|
# 6. Run doctor checks to verify integrity of restored database
|
||||||
|
sudo -u nx9-auth nx9-auth doctor --config /etc/nx9-auth/config.toml
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# nx9-auth Performance Benchmarks
|
||||||
|
|
||||||
|
This document records the performance profiles, throughput (QPS), and latency percentiles for critical authentication pathways in `nx9-auth`.
|
||||||
|
|
||||||
|
These benchmarks were measured using the embedded `bench` binary (`cargo run --bin bench` or `cargo run --release --bin bench`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Benchmark Results
|
||||||
|
|
||||||
|
### 1. Password Verification (Argon2id KDF)
|
||||||
|
|
||||||
|
Password verification is CPU-bound and deliberately computationally heavy to protect against offline brute-force attacks.
|
||||||
|
|
||||||
|
#### Production Configuration (64 MiB memory, 3 iterations, 1 parallelism)
|
||||||
|
- **Requests/sec**: `0.63` (highly secure)
|
||||||
|
- **P50 Latency**: `1578.48 ms`
|
||||||
|
- **P95 Latency**: `1600.56 ms`
|
||||||
|
- **P99 Latency**: `1600.56 ms`
|
||||||
|
|
||||||
|
#### Fast/Test Configuration (4 MiB memory, 1 iteration, 1 parallelism)
|
||||||
|
- **Requests/sec**: `32.07`
|
||||||
|
- **P50 Latency**: `31.15 ms`
|
||||||
|
- **P95 Latency**: `31.56 ms`
|
||||||
|
- **P99 Latency**: `31.73 ms`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Session and Token Validation (BLAKE3 Hashing + SQLite)
|
||||||
|
|
||||||
|
Session and PAT validation do not run the heavy Argon2id algorithm. Instead, they use BLAKE3 hashing and look up the session/token in SQLite, updating the `last_seen_at`/`last_used_at` timestamps. These paths are extremely fast.
|
||||||
|
|
||||||
|
### Session Validation (Cookie authentication)
|
||||||
|
- **Requests/sec**: `9,259.47`
|
||||||
|
- **P50 Latency**: `0.10 ms`
|
||||||
|
- **P95 Latency**: `0.16 ms`
|
||||||
|
- **P99 Latency**: `0.24 ms`
|
||||||
|
|
||||||
|
### Personal Access Token (PAT) Validation
|
||||||
|
- **Requests/sec**: `9,500.90`
|
||||||
|
- **P50 Latency**: `0.09 ms`
|
||||||
|
- **P95 Latency**: `0.16 ms`
|
||||||
|
- **P99 Latency**: `0.21 ms`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Takeaways
|
||||||
|
|
||||||
|
1. **Security & Latency Tradeoff**: The password login pathway is computationally heavy (~1.6 seconds) to guarantee state-of-the-art protection against hardware brute-force attacks.
|
||||||
|
2. **Ultra-Fast Session/PAT Verification**: Once a user is authenticated, microservice session and PAT validation checks are extremely cheap (~0.1ms), enabling low-overhead checks on every inbound API call for consumer systems like BZOD.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# nx9-auth Deployment Guide
|
||||||
|
|
||||||
|
This guide describes how to deploy and upgrade `nx9-auth` on production systems.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Debian- or Ubuntu-compatible Linux system.
|
||||||
|
- `systemd` init system.
|
||||||
|
- Root or sudo privileges.
|
||||||
|
- Pre-compiled `nx9-auth` release binary (get it from the release package `dist/nx9-auth`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Fresh Installation
|
||||||
|
|
||||||
|
To install `nx9-auth` as a systemd service, run the `deploy.sh` script with root privileges:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy.sh /path/to/compiled/nx9-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will automatically:
|
||||||
|
1. Create a dedicated system user `nx9-auth`.
|
||||||
|
2. Setup system directories:
|
||||||
|
- Config directory: `/etc/nx9-auth/`
|
||||||
|
- Data directory: `/var/lib/nx9-auth/`
|
||||||
|
- Logs directory: `/var/log/nx9-auth/`
|
||||||
|
3. Copy the binary to `/usr/local/bin/nx9-auth`.
|
||||||
|
4. Generate a default configuration file `/etc/nx9-auth/config.toml` (if not already present).
|
||||||
|
5. Install and configure a hardened systemd service file `/etc/systemd/system/nx9-auth.service`.
|
||||||
|
6. Run database migrations.
|
||||||
|
7. Start the service.
|
||||||
|
8. Execute diagnostic check (`doctor` command).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration
|
||||||
|
|
||||||
|
Modify `/etc/nx9-auth/config.toml` to customize settings.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[server]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8655
|
||||||
|
|
||||||
|
[database]
|
||||||
|
path = "/var/lib/nx9-auth/auth.db"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
session_ttl_hours = 24
|
||||||
|
session_absolute_ttl_days = 30
|
||||||
|
token_ttl_days = 365
|
||||||
|
argon2_memory = 65536
|
||||||
|
argon2_iterations = 3
|
||||||
|
argon2_parallelism = 1
|
||||||
|
|
||||||
|
[audit]
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
|
||||||
|
After modifying the configuration, restart the service:
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart nx9-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Initial Setup
|
||||||
|
|
||||||
|
Once the service is deployed, create your first administrative user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u nx9-auth nx9-auth create-admin my-admin-username --config /etc/nx9-auth/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Upgrade Installation
|
||||||
|
|
||||||
|
Upgrading `nx9-auth` is safe and preserves both the configuration and the database.
|
||||||
|
|
||||||
|
1. Stop the active service:
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop nx9-auth
|
||||||
|
```
|
||||||
|
2. Run the `deploy.sh` script pointing to the new binary:
|
||||||
|
```bash
|
||||||
|
sudo bash deploy.sh /path/to/new/nx9-auth
|
||||||
|
```
|
||||||
|
*Note: Since the configuration file and database already exist, the deploy script will skip creating them, safely leaving existing user accounts, sessions, and logs untouched.*
|
||||||
|
3. Verify the deployment:
|
||||||
|
```bash
|
||||||
|
sudo -u nx9-auth nx9-auth doctor --config /etc/nx9-auth/config.toml
|
||||||
|
```
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# Running nx9-auth in Docker
|
||||||
|
|
||||||
|
This guide explains how to build, run, initialize, and manage `nx9-auth` using Docker and Docker Compose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Build the Docker Image
|
||||||
|
|
||||||
|
To build the Docker image locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t nx9-auth:0.1.0-rc1 .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Local Development Stack (Docker Compose)
|
||||||
|
|
||||||
|
The local stack runs with isolated named volumes to store database and configurations without cluttering host folders:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nx9-auth:
|
||||||
|
build: .
|
||||||
|
container_name: nx9-auth
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8655:8655"
|
||||||
|
volumes:
|
||||||
|
- nx9-auth-config:/etc/nx9-auth
|
||||||
|
- nx9-auth-db:/var/lib/nx9-auth
|
||||||
|
- nx9-auth-state:/var/log/nx9-auth
|
||||||
|
- nx9-auth-backups:/var/backups/nx9-auth
|
||||||
|
environment:
|
||||||
|
- NX9_AUTH_CONFIG=/etc/nx9-auth/config.toml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8655/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nx9-auth-config:
|
||||||
|
nx9-auth-db:
|
||||||
|
nx9-auth-state:
|
||||||
|
nx9-auth-backups:
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps to Run
|
||||||
|
|
||||||
|
1. **Start the service in the background**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Initialize config and database** (interactive setup):
|
||||||
|
```bash
|
||||||
|
docker exec -it nx9-auth nx9-auth init
|
||||||
|
```
|
||||||
|
*Note: If you need to run non-interactively (e.g. in CI), run:*
|
||||||
|
```bash
|
||||||
|
docker exec -it nx9-auth nx9-auth init --non-interactive --admin-user admin --admin-password 'YourSecurePasswordHere'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check status**:
|
||||||
|
Verify the logs or query health check endpoints from the host:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8655/health
|
||||||
|
curl http://127.0.0.1:8655/version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. CasaOS Deployment (Production)
|
||||||
|
|
||||||
|
For production deployment on CasaOS, volumes are mapped to the host `/DATA/AppData/nx9-auth` directories:
|
||||||
|
|
||||||
|
### Directory Mapping Layout
|
||||||
|
|
||||||
|
| Host Path | Container Path | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/DATA/AppData/nx9-auth/config` | `/etc/nx9-auth` | Contains `config.toml` |
|
||||||
|
| `/DATA/AppData/nx9-auth/db` | `/var/lib/nx9-auth` | Contains `auth.db` |
|
||||||
|
| `/DATA/AppData/nx9-auth/state` | `/var/log/nx9-auth` | Logs and session files |
|
||||||
|
| `/DATA/AppData/nx9-auth/backups` | `/var/backups/nx9-auth` | Database snapshots |
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
CasaOS users can import the `compose.casaos.yml` file via the custom install option. After deployment, execute the init flow inside the container:
|
||||||
|
```bash
|
||||||
|
docker exec -it nx9-auth nx9-auth init
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backups
|
||||||
|
|
||||||
|
To trigger a transactionally consistent online SQLite database backup inside the container:
|
||||||
|
```bash
|
||||||
|
docker exec -it nx9-auth nx9-auth backup /var/backups/nx9-auth/auth-backup.db
|
||||||
|
```
|
||||||
|
The backup will be written directly to `/var/backups/nx9-auth/auth-backup.db` inside the container, which maps to the host's backups directory (e.g. `./backups/` or `/DATA/AppData/nx9-auth/backups/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Upgrade
|
||||||
|
|
||||||
|
To upgrade the container to a newer release:
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# BZOD Consumer Integration Guide
|
||||||
|
|
||||||
|
This guide details how BZOD consumes authentication and authorization services provided by `nx9-auth`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Authentication
|
||||||
|
|
||||||
|
To authenticate a user and establish a session, send a `POST` request to `/api/v1/auth/login`.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Path**: `/api/v1/auth/login`
|
||||||
|
- **Headers**: `Content-Type: application/json`
|
||||||
|
- **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "super_secure_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
- **Status**: `200 OK`
|
||||||
|
- **Headers**: `Set-Cookie: nx9_session=<session_token>; HttpOnly; Secure; SameSite=Lax; Path=/`
|
||||||
|
- **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Session Validation
|
||||||
|
|
||||||
|
To validate an existing session cookie and get the authenticated user's profile, roles, and permissions, make a `GET` request to `/api/v1/auth/me`.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Path**: `/api/v1/auth/me`
|
||||||
|
- **Headers**: Include the `nx9_session` cookie in the request.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
- **Status**: `200 OK`
|
||||||
|
- **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"id": "e4d3a2b1-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
|
||||||
|
"username": "admin",
|
||||||
|
"status": "active",
|
||||||
|
"last_login_at": "2026-06-21T18:09:13Z",
|
||||||
|
"created_at": "2026-06-20T12:00:00Z"
|
||||||
|
},
|
||||||
|
"roles": ["admin"],
|
||||||
|
"permissions": ["users:create", "users:update", "users:delete", "tokens:create", "tokens:revoke"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Personal Access Token (PAT) Authentication
|
||||||
|
|
||||||
|
For programmatic API access (service-to-service or CLI usage), clients can authenticate using a Personal Access Token (PAT) passed in the `Authorization` header.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
- **Headers**: `Authorization: Bearer nx9_pat_<64_hex_chars>`
|
||||||
|
|
||||||
|
For example:
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer nx9_pat_29b2fd8c34f0f089..." https://auth.nx9.local/api/v1/auth/me
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Permissions Mapping
|
||||||
|
|
||||||
|
The following table maps BZOD features and features to their required `nx9-auth` permissions:
|
||||||
|
|
||||||
|
| BZOD Feature / Action | Required Permission | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Create link | `links:create` | Allows creating new shortened links |
|
||||||
|
| Delete link | `links:delete` | Allows deleting existing shortened links |
|
||||||
|
| View link stats | `links:stats` | Allows viewing redirection analytics and link statistics |
|
||||||
|
| Create user accounts | `users:create` | Allows administrative user creation |
|
||||||
|
| Modify user status | `users:update` | Allows enabling, disabling, or locking users |
|
||||||
|
| Delete user accounts | `users:delete` | Allows soft-deleting/disabling users |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Unified Error Payload
|
||||||
|
|
||||||
|
All `nx9-auth` errors return a unified JSON payload format mapping to standard HTTP status codes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Reason for the error",
|
||||||
|
"code": "error_code"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Standard Status Codes & Codes Mapping
|
||||||
|
|
||||||
|
| HTTP Status | Code | Description | Example Error |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `401 Unauthorized` | `unauthorized` | Credentials are invalid, or session/token is missing/expired | `{"error": "invalid credentials", "code": "unauthorized"}` |
|
||||||
|
| `403 Forbidden` | `forbidden` | Authenticated user lacks the required permission | `{"error": "insufficient permissions", "code": "forbidden"}` |
|
||||||
|
| `404 Not Found` | `not_found` | Resource does not exist | `{"error": "resource not found", "code": "not_found"}` |
|
||||||
|
| `409 Conflict` | `conflict` | Unique constraint violation (e.g. username taken) | `{"error": "conflict: username already taken", "code": "conflict"}` |
|
||||||
|
| `422 Unprocessable` | `invalid_input` | Request body or payload format is invalid | `{"error": "invalid input: username cannot be empty", "code": "invalid_input"}` |
|
||||||
|
| `429 Too Many Requests` | `rate_limited` | Rate limit threshold exceeded | `{"error": "too many requests", "code": "rate_limited"}` |
|
||||||
|
| `500 Internal Error` | `internal_error` | Database query failure or unexpected server error | `{"error": "internal error", "code": "internal_error"}` |
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=nx9-auth Identity and Access Management Service
|
||||||
|
Documentation=https://github.com/nx9/nx9-auth
|
||||||
|
After=network.target
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=nx9-auth
|
||||||
|
Group=nx9-auth
|
||||||
|
ExecStart=/usr/local/bin/nx9-auth serve --config /etc/nx9-auth/config.toml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
TimeoutStopSec=10s
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
|
||||||
|
# Writable paths (everything else is read-only via ProtectSystem=strict)
|
||||||
|
ReadWritePaths=/var/lib/nx9-auth /var/log/nx9-auth
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=nx9-auth
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <version-tag> (e.g., v0.1.0-beta1)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG_VERSION="$1"
|
||||||
|
# Ensure it starts with v
|
||||||
|
if [[ ! "$TAG_VERSION" =~ ^v ]]; then
|
||||||
|
echo "Error: version-tag must start with 'v' (e.g., v0.1.0-beta1)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip the leading 'v'
|
||||||
|
STRIPPED_VERSION="${TAG_VERSION#v}"
|
||||||
|
|
||||||
|
# Extract version from Cargo.toml
|
||||||
|
CARGO_VERSION=$(grep -m 1 "^version = " Cargo.toml | awk -F '"' '{print $2}')
|
||||||
|
|
||||||
|
if [ "$STRIPPED_VERSION" != "$CARGO_VERSION" ]; then
|
||||||
|
echo "Error: Version mismatch! Git tag version ($STRIPPED_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 1. Checking code formatting ==="
|
||||||
|
cargo fmt --check
|
||||||
|
|
||||||
|
echo "=== 2. Running clippy ==="
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
echo "=== 3. Running test suite ==="
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
echo "=== 4. Building release binary ==="
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
echo "=== 5. Packaging release ==="
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
# Copy binary
|
||||||
|
cp target/release/nx9-auth dist/
|
||||||
|
strip dist/nx9-auth || true
|
||||||
|
|
||||||
|
# Create VERSION file
|
||||||
|
echo "$TAG_VERSION" > dist/VERSION
|
||||||
|
|
||||||
|
# Generate SHA256SUMS
|
||||||
|
cd dist
|
||||||
|
sha256sum nx9-auth VERSION > SHA256SUMS
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo "=== Release $TAG_VERSION packaged successfully in dist/ ==="
|
||||||
|
ls -l dist/
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
use axum::{Json, extract::State};
|
||||||
|
use axum_extra::extract::{CookieJar, cookie::Cookie};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
audit::{self, AuditEvent},
|
||||||
|
db::models::AuditSeverity,
|
||||||
|
db::repository::users as user_repo,
|
||||||
|
error::{AppError, Result},
|
||||||
|
identity::{permissions, roles},
|
||||||
|
middleware::{audit::AuditContext, auth::AuthUser},
|
||||||
|
security::{passwords, sessions},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Login ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/login
|
||||||
|
pub async fn login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ctx: AuditContext,
|
||||||
|
jar: CookieJar,
|
||||||
|
Json(body): Json<LoginRequest>,
|
||||||
|
) -> Result<(CookieJar, Json<Value>)> {
|
||||||
|
let ip = ctx.ip_address.as_deref();
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
if let Some(ip_str) = &ctx.ip_address {
|
||||||
|
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||||
|
state.rate_limiter.check(ip_addr)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user
|
||||||
|
let user_opt = user_repo::find_by_username(&state.pool, &body.username)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let mut is_authed = false;
|
||||||
|
let mut final_user = None;
|
||||||
|
|
||||||
|
if let Some(user) = user_opt {
|
||||||
|
let password_ok = passwords::verify_password(&body.password, &user.password_hash)?;
|
||||||
|
if password_ok && user.is_active() {
|
||||||
|
is_authed = true;
|
||||||
|
final_user = Some(user);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Run dummy verify to take same execution time
|
||||||
|
passwords::verify_dummy(&state.config.security)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_authed {
|
||||||
|
record_login_failure(&state, &body.username, ip, ctx.user_agent.as_deref()).await;
|
||||||
|
if let Some(ip_str) = &ctx.ip_address {
|
||||||
|
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||||
|
state.rate_limiter.record_failure(ip_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = final_user.unwrap();
|
||||||
|
|
||||||
|
// Clear rate limit on success
|
||||||
|
if let Some(ip_str) = &ctx.ip_address {
|
||||||
|
if let Ok(ip_addr) = ip_str.parse::<std::net::IpAddr>() {
|
||||||
|
state.rate_limiter.record_success(ip_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
let (session, raw_token) = sessions::create_session(
|
||||||
|
&state.pool,
|
||||||
|
&user.id,
|
||||||
|
ip,
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
&state.config.security,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update last_login_at and audit in the same transaction
|
||||||
|
if let Ok(mut tx) = state.pool.begin().await {
|
||||||
|
let _ = user_repo::set_last_login(&mut tx, &user.id).await;
|
||||||
|
let _ = audit::log(
|
||||||
|
&mut tx,
|
||||||
|
AuditEvent {
|
||||||
|
actor_id: Some(&user.id),
|
||||||
|
target_id: Some(&user.id),
|
||||||
|
action: "login_success",
|
||||||
|
resource_type: "session",
|
||||||
|
resource_id: Some(&session.id),
|
||||||
|
severity: AuditSeverity::Info,
|
||||||
|
ip,
|
||||||
|
ua: ctx.user_agent.as_deref(),
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = tx.commit().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
event = "login_success",
|
||||||
|
user_id = %user.id,
|
||||||
|
username = %user.username,
|
||||||
|
ip = ip.unwrap_or("unknown"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build secure session cookie using time::Duration for max_age
|
||||||
|
let max_age_secs = state.config.security.session_absolute_ttl_days as i64 * 86400;
|
||||||
|
let mut cookie = Cookie::new(sessions::SESSION_COOKIE, raw_token);
|
||||||
|
cookie.set_http_only(true);
|
||||||
|
cookie.set_secure(true);
|
||||||
|
cookie.set_same_site(axum_extra::extract::cookie::SameSite::Lax);
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookie.set_max_age(time::Duration::seconds(max_age_secs));
|
||||||
|
|
||||||
|
Ok((jar.add(cookie), Json(json!({ "success": true }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_login_failure(
|
||||||
|
state: &AppState,
|
||||||
|
username: &str,
|
||||||
|
ip: Option<&str>,
|
||||||
|
ua: Option<&str>,
|
||||||
|
) {
|
||||||
|
if let Ok(mut tx) = state.pool.begin().await {
|
||||||
|
let _ = audit::log(
|
||||||
|
&mut tx,
|
||||||
|
AuditEvent {
|
||||||
|
actor_id: None,
|
||||||
|
target_id: None,
|
||||||
|
action: "login_failed",
|
||||||
|
resource_type: "session",
|
||||||
|
resource_id: None,
|
||||||
|
severity: AuditSeverity::Warning,
|
||||||
|
ip,
|
||||||
|
ua,
|
||||||
|
metadata: Some(&format!(r#"{{"username":"{}"}}"#, username)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = tx.commit().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
event = "login_failed",
|
||||||
|
username = %username,
|
||||||
|
ip = ip.unwrap_or("unknown"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// POST /api/v1/auth/logout
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> Result<(CookieJar, Json<Value>)> {
|
||||||
|
if let Some(session_id) = &auth.session_id {
|
||||||
|
sessions::revoke_session(&state.pool, session_id).await?;
|
||||||
|
|
||||||
|
// Audit log for logout
|
||||||
|
if let Ok(mut tx) = state.pool.begin().await {
|
||||||
|
let _ = audit::log(
|
||||||
|
&mut tx,
|
||||||
|
AuditEvent {
|
||||||
|
actor_id: Some(&auth.user.id),
|
||||||
|
target_id: Some(&auth.user.id),
|
||||||
|
action: "logout",
|
||||||
|
resource_type: "session",
|
||||||
|
resource_id: Some(session_id),
|
||||||
|
severity: AuditSeverity::Info,
|
||||||
|
ip: None,
|
||||||
|
ua: None,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let _ = tx.commit().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut removal = Cookie::from(sessions::SESSION_COOKIE);
|
||||||
|
removal.set_path("/");
|
||||||
|
let removed = jar.remove(removal);
|
||||||
|
Ok((removed, Json(json!({ "success": true }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Me ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MeResponse {
|
||||||
|
pub user: UserView,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserView {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub status: String,
|
||||||
|
pub last_login_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/auth/me
|
||||||
|
pub async fn me(State(state): State<AppState>, auth: AuthUser) -> Result<Json<MeResponse>> {
|
||||||
|
let user_roles = roles::list_user_roles(&state.pool, &auth.user.id).await?;
|
||||||
|
let user_perms = permissions::list_user_permissions(&state.pool, &auth.user.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(MeResponse {
|
||||||
|
user: UserView {
|
||||||
|
id: auth.user.id.clone(),
|
||||||
|
username: auth.user.username.clone(),
|
||||||
|
status: auth.user.status().to_string(),
|
||||||
|
last_login_at: auth.user.last_login_at.clone(),
|
||||||
|
created_at: auth.user.created_at.clone(),
|
||||||
|
},
|
||||||
|
roles: user_roles.into_iter().map(|r| r.name).collect(),
|
||||||
|
permissions: user_perms,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// GET /health
|
||||||
|
pub async fn health() -> Json<Value> {
|
||||||
|
Json(json!({ "status": "ok" }))
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod health;
|
||||||
|
pub mod router;
|
||||||
|
pub mod tokens;
|
||||||
|
pub mod users;
|
||||||
|
pub mod version;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
use tower_http::{
|
||||||
|
compression::CompressionLayer,
|
||||||
|
cors::{Any, CorsLayer},
|
||||||
|
trace::TraceLayer,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{auth, health, tokens, users, version},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build the full Axum application router.
|
||||||
|
pub fn build(state: AppState) -> Router {
|
||||||
|
let api_v1 = Router::new()
|
||||||
|
// Auth
|
||||||
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/logout", post(auth::logout))
|
||||||
|
.route("/auth/me", get(auth::me))
|
||||||
|
// Users
|
||||||
|
.route("/users", get(users::list_users).post(users::create_user))
|
||||||
|
.route(
|
||||||
|
"/users/{id}",
|
||||||
|
get(users::get_user)
|
||||||
|
.patch(users::update_user)
|
||||||
|
.delete(users::delete_user),
|
||||||
|
)
|
||||||
|
// Tokens
|
||||||
|
.route(
|
||||||
|
"/tokens",
|
||||||
|
get(tokens::list_tokens).post(tokens::create_token),
|
||||||
|
)
|
||||||
|
.route("/tokens/{id}", delete(tokens::revoke_token));
|
||||||
|
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(health::health))
|
||||||
|
.route("/version", get(version::version))
|
||||||
|
.nest("/api/v1", api_v1)
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::models::ApiToken,
|
||||||
|
db::repository::tokens as token_repo,
|
||||||
|
error::{AppError, Result},
|
||||||
|
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||||
|
security::tokens as token_security,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Response type ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ApiToken> for TokenResponse {
|
||||||
|
fn from(t: ApiToken) -> Self {
|
||||||
|
Self {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
last_used_at: t.last_used_at,
|
||||||
|
expires_at: t.expires_at,
|
||||||
|
created_at: t.created_at,
|
||||||
|
revoked: t.revoked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/tokens ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateTokenRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a personal access token for the authenticated user.
|
||||||
|
///
|
||||||
|
/// The raw token is returned **once** in this response and never stored.
|
||||||
|
pub async fn create_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ctx: AuditContext,
|
||||||
|
Json(body): Json<CreateTokenRequest>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
if body.name.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("token name cannot be empty".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (token, raw) = token_security::create_token(
|
||||||
|
&state.pool,
|
||||||
|
&auth.user.id,
|
||||||
|
&body.name,
|
||||||
|
&state.config.security,
|
||||||
|
Some(&auth.user.id),
|
||||||
|
ctx.ip_address.as_deref(),
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
event = "token_created",
|
||||||
|
user_id = %auth.user.id,
|
||||||
|
token_id = %token.id,
|
||||||
|
name = %token.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"token": TokenResponse::from(token),
|
||||||
|
"raw_token": raw,
|
||||||
|
"warning": "Store this token securely — it will not be shown again.",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/tokens ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// List the authenticated user's own tokens.
|
||||||
|
pub async fn list_tokens(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
|
||||||
|
let tokens = token_repo::list_for_user(&state.pool, &auth.user.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let views: Vec<TokenResponse> = tokens.into_iter().map(TokenResponse::from).collect();
|
||||||
|
Ok(Json(json!({ "tokens": views })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/tokens/:id ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Revoke a token. The caller must own the token or hold `tokens:revoke`.
|
||||||
|
pub async fn revoke_token(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ctx: AuditContext,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
let token = token_repo::find_by_id(&state.pool, &id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
// Must be owner or have tokens:revoke permission
|
||||||
|
if token.user_id != auth.user.id {
|
||||||
|
require(&state.pool, &auth.user.id, "tokens:revoke").await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
token_security::revoke_token(
|
||||||
|
&state.pool,
|
||||||
|
&id,
|
||||||
|
Some(&auth.user.id),
|
||||||
|
ctx.ip_address.as_deref(),
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "success": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, State},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::models::Tenant,
|
||||||
|
db::models::{User, UserStatus},
|
||||||
|
error::{AppError, Result},
|
||||||
|
identity::users as identity,
|
||||||
|
middleware::{audit::AuditContext, auth::AuthUser, permissions::require},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Response type ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UserResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub status: String,
|
||||||
|
pub last_login_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<User> for UserResponse {
|
||||||
|
fn from(u: User) -> Self {
|
||||||
|
Self {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
status: UserStatus::from_i32(u.status).to_string(),
|
||||||
|
last_login_at: u.last_login_at,
|
||||||
|
created_at: u.created_at,
|
||||||
|
updated_at: u.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/users ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn list_users(State(state): State<AppState>, auth: AuthUser) -> Result<Json<Value>> {
|
||||||
|
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||||
|
|
||||||
|
let users = identity::list_users(&state.pool, Tenant::DEFAULT_ID).await?;
|
||||||
|
let views: Vec<UserResponse> = users.into_iter().map(UserResponse::from).collect();
|
||||||
|
Ok(Json(json!({ "users": views })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/users ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateUserRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ctx: AuditContext,
|
||||||
|
Json(body): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||||
|
|
||||||
|
let user = identity::create_user(
|
||||||
|
&state.pool,
|
||||||
|
&state.config.security,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
&body.username,
|
||||||
|
&body.password,
|
||||||
|
Some(&auth.user.id),
|
||||||
|
ctx.ip_address.as_deref(),
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/users/:id ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn get_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
// Users may view themselves; admins may view anyone
|
||||||
|
if id != auth.user.id {
|
||||||
|
require(&state.pool, &auth.user.id, "users:create").await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = identity::get_user(&state.pool, &id).await?;
|
||||||
|
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PATCH /api/v1/users/:id ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateUserRequest {
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ctx: AuditContext,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<UpdateUserRequest>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
require(&state.pool, &auth.user.id, "users:update").await?;
|
||||||
|
|
||||||
|
if let Some(status_str) = &body.status {
|
||||||
|
let status = match status_str.as_str() {
|
||||||
|
"active" => UserStatus::Active as i32,
|
||||||
|
"disabled" => UserStatus::Disabled as i32,
|
||||||
|
"locked" => UserStatus::Locked as i32,
|
||||||
|
other => return Err(AppError::InvalidInput(format!("unknown status: {other}"))),
|
||||||
|
};
|
||||||
|
identity::update_status(
|
||||||
|
&state.pool,
|
||||||
|
&id,
|
||||||
|
status,
|
||||||
|
Some(&auth.user.id),
|
||||||
|
ctx.ip_address.as_deref(),
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = identity::get_user(&state.pool, &id).await?;
|
||||||
|
Ok(Json(json!({ "user": UserResponse::from(user) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/users/:id ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Soft-deletes a user by setting status = Disabled. Never hard-deletes.
|
||||||
|
pub async fn delete_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
ctx: AuditContext,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<Value>> {
|
||||||
|
require(&state.pool, &auth.user.id, "users:delete").await?;
|
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if id == auth.user.id {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"cannot disable your own account".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
identity::update_status(
|
||||||
|
&state.pool,
|
||||||
|
&id,
|
||||||
|
UserStatus::Disabled as i32,
|
||||||
|
Some(&auth.user.id),
|
||||||
|
ctx.ip_address.as_deref(),
|
||||||
|
ctx.user_agent.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "success": true })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// GET /version
|
||||||
|
///
|
||||||
|
/// Returns build metadata baked in at compile time via `build.rs`.
|
||||||
|
pub async fn version() -> Json<Value> {
|
||||||
|
Json(json!({
|
||||||
|
"name": env!("CARGO_PKG_NAME"),
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"git_commit": env!("GIT_COMMIT"),
|
||||||
|
"build_date": env!("BUILD_DATE"),
|
||||||
|
"rust_version": env!("RUST_VERSION"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::{
|
||||||
|
db::{models::AuditSeverity, repository::audit as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A structured audit event to be persisted and logged.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AuditEvent<'a> {
|
||||||
|
/// The user performing the action (None for system/CLI events).
|
||||||
|
pub actor_id: Option<&'a str>,
|
||||||
|
/// The user being acted upon, if applicable.
|
||||||
|
pub target_id: Option<&'a str>,
|
||||||
|
/// Machine-readable action name (e.g. `"login_success"`, `"user_created"`).
|
||||||
|
pub action: &'a str,
|
||||||
|
/// Resource category (e.g. `"user"`, `"session"`, `"token"`).
|
||||||
|
pub resource_type: &'a str,
|
||||||
|
/// Specific resource ID, if applicable.
|
||||||
|
pub resource_id: Option<&'a str>,
|
||||||
|
/// Event severity.
|
||||||
|
pub severity: AuditSeverity,
|
||||||
|
/// Client IP address.
|
||||||
|
pub ip: Option<&'a str>,
|
||||||
|
/// Client User-Agent string.
|
||||||
|
pub ua: Option<&'a str>,
|
||||||
|
/// Optional structured metadata (serialized JSON string).
|
||||||
|
pub metadata: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> AuditEvent<'a> {
|
||||||
|
/// Convenience constructor for info-level system events with no actor/IP.
|
||||||
|
pub fn system(action: &'a str, resource_type: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
actor_id: None,
|
||||||
|
target_id: None,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
resource_id: None,
|
||||||
|
severity: AuditSeverity::Info,
|
||||||
|
ip: None,
|
||||||
|
ua: None,
|
||||||
|
metadata: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist an audit event to the database and emit a structured log line.
|
||||||
|
///
|
||||||
|
/// This function is intentionally fire-and-forget — a failure to write an
|
||||||
|
/// audit log must never break an otherwise successful operation.
|
||||||
|
pub async fn log(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
event: AuditEvent<'_>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
event = "audit",
|
||||||
|
action = event.action,
|
||||||
|
resource_type = event.resource_type,
|
||||||
|
resource_id = event.resource_id,
|
||||||
|
severity = event.severity.as_str(),
|
||||||
|
actor_id = event.actor_id,
|
||||||
|
target_id = event.target_id,
|
||||||
|
ip = event.ip,
|
||||||
|
);
|
||||||
|
|
||||||
|
repo::insert(
|
||||||
|
tx,
|
||||||
|
&id,
|
||||||
|
event.actor_id,
|
||||||
|
event.target_id,
|
||||||
|
event.action,
|
||||||
|
event.resource_type,
|
||||||
|
event.resource_id,
|
||||||
|
event.severity.as_str(),
|
||||||
|
event.ip,
|
||||||
|
event.ua,
|
||||||
|
event.metadata,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#[allow(clippy::module_inception)]
|
||||||
|
pub mod audit;
|
||||||
|
pub use audit::{AuditEvent, log};
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
use nx9_auth::{
|
||||||
|
config::SecurityConfig,
|
||||||
|
db::{self, models::Tenant},
|
||||||
|
identity::users as identity_users,
|
||||||
|
security::{passwords, sessions, tokens},
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
async fn setup_bench_db() -> (SqlitePool, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/bench_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bench db");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run bench migrations");
|
||||||
|
(pool, db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_stats(name: &str, mut durations: Vec<std::time::Duration>, count: usize) {
|
||||||
|
durations.sort();
|
||||||
|
let total_secs: f64 = durations.iter().map(|d| d.as_secs_f64()).sum();
|
||||||
|
let qps = count as f64 / total_secs;
|
||||||
|
|
||||||
|
let p50 = durations[count / 2];
|
||||||
|
let p95 = durations[(count * 95) / 100];
|
||||||
|
let p99 = durations[(count * 99) / 100];
|
||||||
|
|
||||||
|
println!("{}:", name);
|
||||||
|
println!(" Total ops: {}", count);
|
||||||
|
println!(" Requests/s: {:.2}", qps);
|
||||||
|
println!(" P50 latency: {:.2} ms", p50.as_secs_f64() * 1000.0);
|
||||||
|
println!(" P95 latency: {:.2} ms", p95.as_secs_f64() * 1000.0);
|
||||||
|
println!(" P99 latency: {:.2} ms", p99.as_secs_f64() * 1000.0);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
println!("Starting nx9-auth microbenchmarks...");
|
||||||
|
let (pool, db_path) = setup_bench_db().await;
|
||||||
|
|
||||||
|
// Production security config
|
||||||
|
let sec_cfg = SecurityConfig {
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
session_absolute_ttl_days: 30,
|
||||||
|
token_ttl_days: 365,
|
||||||
|
argon2_memory: 65536, // Production: 64MiB
|
||||||
|
argon2_iterations: 3, // Production: 3 passes
|
||||||
|
argon2_parallelism: 1, // Production: 1 thread
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test security config (low cost to see algorithm overhead vs Argon2 KDF)
|
||||||
|
let fast_sec_cfg = SecurityConfig {
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
session_absolute_ttl_days: 30,
|
||||||
|
token_ttl_days: 365,
|
||||||
|
argon2_memory: 4096,
|
||||||
|
argon2_iterations: 1,
|
||||||
|
argon2_parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create benchmark user
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&fast_sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"bench_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Password Verification Benchmark (Production Cost)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
let prod_hash = passwords::hash_password("super_secure_passphrase_123", &sec_cfg).unwrap();
|
||||||
|
let login_ops = 20;
|
||||||
|
let mut login_durations = Vec::with_capacity(login_ops);
|
||||||
|
|
||||||
|
for _ in 0..login_ops {
|
||||||
|
let start = Instant::now();
|
||||||
|
let ok = passwords::verify_password("super_secure_passphrase_123", &prod_hash).unwrap();
|
||||||
|
assert!(ok);
|
||||||
|
login_durations.push(start.elapsed());
|
||||||
|
}
|
||||||
|
print_stats(
|
||||||
|
"Argon2id Password Verification (Production Config: 64MiB, 3 passes)",
|
||||||
|
login_durations,
|
||||||
|
login_ops,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Password Verification Benchmark (Low Cost)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
let fast_hash = passwords::hash_password("super_secure_passphrase_123", &fast_sec_cfg).unwrap();
|
||||||
|
let fast_login_ops = 100;
|
||||||
|
let mut fast_login_durations = Vec::with_capacity(fast_login_ops);
|
||||||
|
|
||||||
|
for _ in 0..fast_login_ops {
|
||||||
|
let start = Instant::now();
|
||||||
|
let ok = passwords::verify_password("super_secure_passphrase_123", &fast_hash).unwrap();
|
||||||
|
assert!(ok);
|
||||||
|
fast_login_durations.push(start.elapsed());
|
||||||
|
}
|
||||||
|
print_stats(
|
||||||
|
"Argon2id Password Verification (Test/Low Cost Config: 4MiB, 1 pass)",
|
||||||
|
fast_login_durations,
|
||||||
|
fast_login_ops,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. Session Validation Benchmark (BLAKE3 Hashing + SQLite)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
let (_session, raw_token) = sessions::create_session(
|
||||||
|
&pool,
|
||||||
|
&user.id,
|
||||||
|
Some("127.0.0.1"),
|
||||||
|
Some("Bench Agent"),
|
||||||
|
&fast_sec_cfg,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let session_ops = 2000;
|
||||||
|
let mut session_durations = Vec::with_capacity(session_ops);
|
||||||
|
|
||||||
|
for _ in 0..session_ops {
|
||||||
|
let start = Instant::now();
|
||||||
|
let validated = sessions::validate_session(&pool, &raw_token, &fast_sec_cfg)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(validated.is_some());
|
||||||
|
session_durations.push(start.elapsed());
|
||||||
|
}
|
||||||
|
print_stats(
|
||||||
|
"Session Validation (BLAKE3 + SQLite Touch)",
|
||||||
|
session_durations,
|
||||||
|
session_ops,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Personal Access Token (PAT) Verification Benchmark (BLAKE3 + SQLite)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
let (_token, raw_pat) = tokens::create_token(
|
||||||
|
&pool,
|
||||||
|
&user.id,
|
||||||
|
"bench-pat",
|
||||||
|
&fast_sec_cfg,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pat_ops = 2000;
|
||||||
|
let mut pat_durations = Vec::with_capacity(pat_ops);
|
||||||
|
|
||||||
|
for _ in 0..pat_ops {
|
||||||
|
let start = Instant::now();
|
||||||
|
let validated = tokens::validate_token(&pool, &raw_pat).await.unwrap();
|
||||||
|
assert!(validated.is_some());
|
||||||
|
pat_durations.push(start.elapsed());
|
||||||
|
}
|
||||||
|
print_stats(
|
||||||
|
"PAT Validation (BLAKE3 + SQLite Touch)",
|
||||||
|
pat_durations,
|
||||||
|
pat_ops,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(db_path);
|
||||||
|
}
|
||||||
+1020
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,272 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Root configuration loaded from config.toml
|
||||||
|
#[derive(Debug, Deserialize, Clone, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(skip)]
|
||||||
|
pub config_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub server: ServerConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub database: DatabaseConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub security: SecurityConfig,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub audit: AuditConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Interface to listen on.
|
||||||
|
pub host: String,
|
||||||
|
/// Port to listen on.
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct DatabaseConfig {
|
||||||
|
/// Path to the SQLite database file (supports ~ prefix).
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct SecurityConfig {
|
||||||
|
/// Session idle timeout in hours.
|
||||||
|
pub session_ttl_hours: u32,
|
||||||
|
/// Session absolute lifetime in days.
|
||||||
|
pub session_absolute_ttl_days: u32,
|
||||||
|
/// Default API token lifetime in days.
|
||||||
|
pub token_ttl_days: u32,
|
||||||
|
/// Argon2id memory cost (KiB).
|
||||||
|
pub argon2_memory: u32,
|
||||||
|
/// Argon2id iteration count.
|
||||||
|
pub argon2_iterations: u32,
|
||||||
|
/// Argon2id parallelism.
|
||||||
|
pub argon2_parallelism: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct AuditConfig {
|
||||||
|
/// Whether to write events to the audit_logs table.
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
host: "127.0.0.1".to_string(), // Default to loopback for user mode safety
|
||||||
|
port: 8655,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DatabaseConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
let default_db_path = if let Ok(home) = std::env::var("HOME") {
|
||||||
|
Path::new(&home)
|
||||||
|
.join(".local/share/nx9-auth/auth.db")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
} else {
|
||||||
|
"/var/lib/nx9-auth/auth.db".to_string()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
path: default_db_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SecurityConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
session_absolute_ttl_days: 30,
|
||||||
|
token_ttl_days: 365,
|
||||||
|
argon2_memory: 65536,
|
||||||
|
argon2_iterations: 3,
|
||||||
|
argon2_parallelism: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AuditConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { enabled: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn resolve_home_path(path: &str) -> String {
|
||||||
|
if let Some(stripped) = path.strip_prefix("~/") {
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
return Path::new(&home)
|
||||||
|
.join(stripped)
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Resolve path prefixes such as ~ to actual home directories.
|
||||||
|
pub fn resolve_paths(&mut self) {
|
||||||
|
self.database.path = resolve_home_path(&self.database.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and parse config from a TOML file.
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("failed to read config file: {}", path.display()))?;
|
||||||
|
let mut config: Config = toml::from_str(&content)
|
||||||
|
.with_context(|| format!("failed to parse config file: {}", path.display()))?;
|
||||||
|
config.config_path = Some(path.to_path_buf());
|
||||||
|
config.resolve_paths();
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config, falling back to defaults if the file doesn't exist.
|
||||||
|
/// Errors on malformed files.
|
||||||
|
pub fn load_or_default(path: &Path) -> Result<Self> {
|
||||||
|
let mut config = if path.exists() {
|
||||||
|
Self::load(path)?
|
||||||
|
} else {
|
||||||
|
let mut cfg = Self::default();
|
||||||
|
cfg.resolve_paths();
|
||||||
|
cfg
|
||||||
|
};
|
||||||
|
config.config_path = Some(path.to_path_buf());
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical config path candidates in priority order:
|
||||||
|
/// 1. ./config.toml (Current directory override)
|
||||||
|
/// 2. $XDG_CONFIG_HOME/nx9-auth/config.toml or ~/.config/nx9-auth/config.toml
|
||||||
|
/// 3. /etc/nx9-auth/config.toml (System-wide default)
|
||||||
|
pub fn search_paths() -> Vec<PathBuf> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
|
||||||
|
// 1. Current directory override
|
||||||
|
paths.push(PathBuf::from("./config.toml"));
|
||||||
|
|
||||||
|
// 2. ~/.config/nx9-auth/config.toml (or XDG_CONFIG_HOME)
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
if !xdg.is_empty() {
|
||||||
|
paths.push(PathBuf::from(xdg).join("nx9-auth/config.toml"));
|
||||||
|
}
|
||||||
|
} else if let Ok(home) = std::env::var("HOME") {
|
||||||
|
paths.push(PathBuf::from(home).join(".config/nx9-auth/config.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. System-wide default
|
||||||
|
paths.push(PathBuf::from("/etc/nx9-auth/config.toml"));
|
||||||
|
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default user configuration path (~/.config/nx9-auth/config.toml)
|
||||||
|
pub fn default_user_config_path() -> Option<PathBuf> {
|
||||||
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
if !xdg.is_empty() {
|
||||||
|
return Some(PathBuf::from(xdg).join("nx9-auth/config.toml"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
return Some(PathBuf::from(home).join(".config/nx9-auth/config.toml"));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find and load the first existing config file from the search path.
|
||||||
|
/// Returns Ok(None) if no configuration file is found in any search path.
|
||||||
|
pub fn find_and_load(override_path: Option<&Path>) -> Result<Option<Self>> {
|
||||||
|
if let Some(p) = override_path {
|
||||||
|
let mut config = Self::load(p)?;
|
||||||
|
config.config_path = Some(p.to_path_buf());
|
||||||
|
return Ok(Some(config));
|
||||||
|
}
|
||||||
|
for path in Self::search_paths() {
|
||||||
|
if path.exists() {
|
||||||
|
let mut config = Self::load(&path)?;
|
||||||
|
config.config_path = Some(path);
|
||||||
|
return Ok(Some(config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate default TOML content for the `init` command
|
||||||
|
pub fn generate_default_toml() -> &'static str {
|
||||||
|
r#"# nx9-auth configuration file
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Interface to bind on. Use 127.0.0.1 for local/user mode.
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8655
|
||||||
|
|
||||||
|
[database]
|
||||||
|
# Absolute or home-relative path to the SQLite database file.
|
||||||
|
path = "~/.local/share/nx9-auth/auth.db"
|
||||||
|
|
||||||
|
[security]
|
||||||
|
# Session idle timeout in hours.
|
||||||
|
session_ttl_hours = 24
|
||||||
|
# Session absolute lifetime in days.
|
||||||
|
session_absolute_ttl_days = 30
|
||||||
|
# Default API token lifetime in days.
|
||||||
|
token_ttl_days = 365
|
||||||
|
|
||||||
|
# Argon2id verification parameters (production strength recommended).
|
||||||
|
argon2_memory = 65536
|
||||||
|
argon2_iterations = 3
|
||||||
|
argon2_parallelism = 1
|
||||||
|
|
||||||
|
[audit]
|
||||||
|
# Enable structured audit logging to the database.
|
||||||
|
enabled = true
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_defaults() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.server.port, 8655);
|
||||||
|
assert_eq!(cfg.server.host, "127.0.0.1");
|
||||||
|
if std::env::var("HOME").is_ok() {
|
||||||
|
assert!(cfg.database.path.contains(".local/share/nx9-auth/auth.db"));
|
||||||
|
} else {
|
||||||
|
assert_eq!(cfg.database.path, "/var/lib/nx9-auth/auth.db");
|
||||||
|
}
|
||||||
|
assert_eq!(cfg.security.session_ttl_hours, 24);
|
||||||
|
assert_eq!(cfg.security.session_absolute_ttl_days, 30);
|
||||||
|
assert_eq!(cfg.security.token_ttl_days, 365);
|
||||||
|
assert!(cfg.audit.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_paths() {
|
||||||
|
let paths = Config::search_paths();
|
||||||
|
assert!(paths.iter().any(|p| p.to_str().unwrap() == "./config.toml"));
|
||||||
|
assert!(
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.to_str().unwrap() == "/etc/nx9-auth/config.toml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
-- 1 = active, 2 = disabled, 3 = locked
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_login_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE (tenant_id, username)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
email TEXT,
|
||||||
|
full_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
metadata_json TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS permissions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||||
|
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (role_id, permission_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_roles (
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
last_used_at TEXT,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_token_hash ON api_tokens(token_hash);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS service_accounts (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE (tenant_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_accounts_tenant ON service_accounts(tenant_id);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS applications (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_applications_tenant ON applications(tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_applications_slug ON applications(slug);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
target_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_id TEXT,
|
||||||
|
-- 'info', 'warning', 'critical'
|
||||||
|
severity TEXT NOT NULL DEFAULT 'info',
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_severity ON audit_logs(severity);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Seed the default tenant.
|
||||||
|
-- Uses INSERT OR IGNORE so re-running migrations is safe.
|
||||||
|
INSERT OR IGNORE INTO tenants (id, name, slug, enabled)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000001', 'Default', 'default', 1);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- ── Roles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO roles (id, name, description) VALUES
|
||||||
|
('10000000-0000-0000-0000-000000000001', 'admin', 'Full system access'),
|
||||||
|
('10000000-0000-0000-0000-000000000002', 'editor', 'Can manage content and users'),
|
||||||
|
('10000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access');
|
||||||
|
|
||||||
|
-- ── Permissions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO permissions (id, name, description) VALUES
|
||||||
|
('20000000-0000-0000-0000-000000000001', 'users:create', 'Create new user accounts'),
|
||||||
|
('20000000-0000-0000-0000-000000000002', 'users:update', 'Update user accounts'),
|
||||||
|
('20000000-0000-0000-0000-000000000003', 'users:delete', 'Disable user accounts'),
|
||||||
|
('20000000-0000-0000-0000-000000000004', 'tokens:create', 'Create API tokens for any user'),
|
||||||
|
('20000000-0000-0000-0000-000000000005', 'tokens:revoke', 'Revoke API tokens for any user'),
|
||||||
|
('20000000-0000-0000-0000-000000000006', 'roles:manage', 'Assign and revoke roles'),
|
||||||
|
('20000000-0000-0000-0000-000000000007', 'audit:view', 'View audit log entries');
|
||||||
|
|
||||||
|
-- ── Admin role gets all permissions ──────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions;
|
||||||
|
|
||||||
|
-- ── Editor role permissions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES
|
||||||
|
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000001'),
|
||||||
|
('10000000-0000-0000-0000-000000000002', '20000000-0000-0000-0000-000000000002');
|
||||||
|
|
||||||
|
-- ── Default applications ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO applications (id, tenant_id, name, slug, enabled) VALUES
|
||||||
|
('30000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001', 'BZOD', 'bzod', 1),
|
||||||
|
('30000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001', 'ChronoSeal', 'chronoseal', 1),
|
||||||
|
('30000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001', 'nx9-dns', 'nx9-dns', 1);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||||
|
|
||||||
|
/// Create and configure the SQLite connection pool.
|
||||||
|
///
|
||||||
|
/// Enables WAL mode, foreign keys, and a busy timeout so concurrent writers
|
||||||
|
/// do not immediately error — they back off and retry for up to 5 seconds.
|
||||||
|
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
|
||||||
|
// Ensure the parent directory exists
|
||||||
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent).with_context(|| {
|
||||||
|
format!("failed to create database directory: {}", parent.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("sqlite://{}?mode=rwc", path);
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(16)
|
||||||
|
.min_connections(1)
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to open database: {path}"))?;
|
||||||
|
|
||||||
|
// Apply foundational PRAGMAs on every connection
|
||||||
|
sqlx::query("PRAGMA journal_mode = WAL")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("PRAGMA journal_mode")?;
|
||||||
|
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("PRAGMA foreign_keys")?;
|
||||||
|
|
||||||
|
sqlx::query("PRAGMA busy_timeout = 5000")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("PRAGMA busy_timeout")?;
|
||||||
|
|
||||||
|
sqlx::query("PRAGMA synchronous = NORMAL")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("PRAGMA synchronous")?;
|
||||||
|
|
||||||
|
sqlx::query("PRAGMA cache_size = -32768") // 32 MiB page cache
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.context("PRAGMA cache_size")?;
|
||||||
|
|
||||||
|
tracing::info!(path = path, "database pool opened");
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all pending SQLx migrations embedded in `src/db/migrations/`.
|
||||||
|
pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
||||||
|
sqlx::migrate!("src/db/migrations")
|
||||||
|
.run(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to run database migrations")?;
|
||||||
|
tracing::info!("database migrations applied");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod models;
|
||||||
|
pub mod repository;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// A personal access token row from the `api_tokens` table.
|
||||||
|
///
|
||||||
|
/// `token_hash` is the BLAKE3 hex-encoded hash of the raw `nx9_pat_...` token.
|
||||||
|
/// The raw token is displayed exactly once at creation time and never stored.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct ApiToken {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// BLAKE3 hex hash — never expose in API responses.
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub token_hash: String,
|
||||||
|
pub last_used_at: Option<String>,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Application {
|
||||||
|
pub id: String,
|
||||||
|
pub tenant_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// Audit event severity level.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AuditSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuditSeverity {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Info => "info",
|
||||||
|
Self::Warning => "warning",
|
||||||
|
Self::Critical => "critical",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for AuditSeverity {
|
||||||
|
type Err = std::convert::Infallible;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"warning" => Ok(Self::Warning),
|
||||||
|
"critical" => Ok(Self::Critical),
|
||||||
|
_ => Ok(Self::Info),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AuditSeverity {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row from the `audit_logs` table.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct AuditLog {
|
||||||
|
pub id: String,
|
||||||
|
pub actor_user_id: Option<String>,
|
||||||
|
pub target_user_id: Option<String>,
|
||||||
|
pub action: String,
|
||||||
|
pub resource_type: String,
|
||||||
|
pub resource_id: Option<String>,
|
||||||
|
pub severity: String,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub metadata_json: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
pub mod api_token;
|
||||||
|
pub mod application;
|
||||||
|
pub mod audit_log;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod role;
|
||||||
|
pub mod service_account;
|
||||||
|
pub mod session;
|
||||||
|
pub mod tenant;
|
||||||
|
pub mod user;
|
||||||
|
|
||||||
|
pub use api_token::ApiToken;
|
||||||
|
pub use application::Application;
|
||||||
|
pub use audit_log::{AuditLog, AuditSeverity};
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use permission::Permission;
|
||||||
|
pub use role::Role;
|
||||||
|
pub use service_account::ServiceAccount;
|
||||||
|
pub use session::Session;
|
||||||
|
pub use tenant::Tenant;
|
||||||
|
pub use user::{User, UserStatus};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Permission {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Role {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct ServiceAccount {
|
||||||
|
pub id: String,
|
||||||
|
pub tenant_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// A session row from the `sessions` table.
|
||||||
|
///
|
||||||
|
/// `token_hash` is the BLAKE3 hex-encoded hash of the raw session token.
|
||||||
|
/// The raw token is stored in a cookie and never persisted.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
/// BLAKE3 hex hash of the raw cookie value.
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub token_hash: String,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
/// Absolute expiry — the session is dead after this regardless of activity.
|
||||||
|
pub expires_at: String,
|
||||||
|
/// Idle timeout — updated on each authenticated request.
|
||||||
|
pub last_seen_at: String,
|
||||||
|
pub revoked: bool,
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct Tenant {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tenant {
|
||||||
|
pub const DEFAULT_ID: &'static str = "00000000-0000-0000-0000-000000000001";
|
||||||
|
pub const DEFAULT_SLUG: &'static str = "default";
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
|
||||||
|
/// User account status.
|
||||||
|
///
|
||||||
|
/// Stored as INTEGER in SQLite: 1 = Active, 2 = Disabled, 3 = Locked.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum UserStatus {
|
||||||
|
Active = 1,
|
||||||
|
Disabled = 2,
|
||||||
|
Locked = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserStatus {
|
||||||
|
pub fn from_i32(v: i32) -> Self {
|
||||||
|
match v {
|
||||||
|
2 => Self::Disabled,
|
||||||
|
3 => Self::Locked,
|
||||||
|
_ => Self::Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_i32(self) -> i32 {
|
||||||
|
self as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
Self::Locked => "locked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for UserStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user account row from the `users` table.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub tenant_id: String,
|
||||||
|
pub username: String,
|
||||||
|
/// Argon2id PHC string — never expose in API responses.
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub password_hash: String,
|
||||||
|
/// Raw integer status — use `status()` for the typed enum.
|
||||||
|
pub status: i32,
|
||||||
|
pub last_login_at: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
/// Typed status accessor.
|
||||||
|
pub fn status(&self) -> UserStatus {
|
||||||
|
UserStatus::from_i32(self.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_active(&self) -> bool {
|
||||||
|
self.status() == UserStatus::Active
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::Application;
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
tenant_id: &str,
|
||||||
|
name: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Application, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Application>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO applications (id, tenant_id, name, slug)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_slug(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<Application>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE slug = ?")
|
||||||
|
.bind(slug)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Application>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<Application>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE tenant_id = ? ORDER BY name")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_enabled(pool: &SqlitePool, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE applications SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::AuditLog;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn insert(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
actor_user_id: Option<&str>,
|
||||||
|
target_user_id: Option<&str>,
|
||||||
|
action: &str,
|
||||||
|
resource_type: &str,
|
||||||
|
resource_id: Option<&str>,
|
||||||
|
severity: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
metadata_json: Option<&str>,
|
||||||
|
) -> Result<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(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<AuditLog>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, AuditLog>("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?")
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod applications;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod service_accounts;
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod tokens;
|
||||||
|
pub mod users;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
/// Return all permission names held by a user (via their roles).
|
||||||
|
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<String>, sqlx::Error> {
|
||||||
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT p.name
|
||||||
|
FROM permissions p
|
||||||
|
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||||
|
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
ORDER BY p.name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|(name,)| name).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a user holds a specific named permission.
|
||||||
|
pub async fn user_has_permission(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
permission_name: &str,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let row: (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM permissions p
|
||||||
|
JOIN role_permissions rp ON rp.permission_id = p.id
|
||||||
|
JOIN user_roles ur ON ur.role_id = rp.role_id
|
||||||
|
WHERE ur.user_id = ? AND p.name = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(permission_name)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.0 > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::Role;
|
||||||
|
|
||||||
|
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<Role>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Role>("SELECT * FROM roles ORDER BY name")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_name(pool: &SqlitePool, name: &str) -> Result<Option<Role>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE name = ?")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<Role>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Role>("SELECT * FROM roles WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<Role>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Role>(
|
||||||
|
r#"
|
||||||
|
SELECT r.* FROM roles r
|
||||||
|
JOIN user_roles ur ON ur.role_id = r.id
|
||||||
|
WHERE ur.user_id = ?
|
||||||
|
ORDER BY r.name
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn assign_to_user(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
user_id: &str,
|
||||||
|
role_id: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO user_roles (user_id, role_id) VALUES (?, ?)")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_from_user(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
user_id: &str,
|
||||||
|
role_id: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_role_exists(pool: &SqlitePool) -> Result<bool, sqlx::Error> {
|
||||||
|
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.0 > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::ServiceAccount;
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
tenant_id: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
) -> Result<ServiceAccount, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ServiceAccount>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO service_accounts (id, tenant_id, name, description)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(description)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<Option<ServiceAccount>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ServiceAccount>("SELECT * FROM service_accounts WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<ServiceAccount>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ServiceAccount>(
|
||||||
|
"SELECT * FROM service_accounts WHERE tenant_id = ? ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_enabled(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE service_accounts SET enabled = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(enabled)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::Session;
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
expires_at: &str,
|
||||||
|
) -> Result<Session, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Session>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO sessions (id, user_id, token_hash, ip_address, user_agent, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(ip_address)
|
||||||
|
.bind(user_agent)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_token_hash(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<Session>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Session>("SELECT * FROM sessions WHERE token_hash = ? AND revoked = 0")
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE sessions SET revoked = 1 WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_all_for_user(pool: &SqlitePool, user_id: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE sessions SET revoked = 1 WHERE user_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_last_seen(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete sessions that are expired or revoked. Called once at startup.
|
||||||
|
pub async fn cleanup_expired(pool: &SqlitePool) -> Result<u64, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE revoked = 1
|
||||||
|
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::ApiToken;
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
token_hash: &str,
|
||||||
|
expires_at: Option<&str>,
|
||||||
|
) -> Result<ApiToken, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ApiToken>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO api_tokens (id, user_id, name, token_hash, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(token_hash)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_hash(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
token_hash: &str,
|
||||||
|
) -> Result<Option<ApiToken>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked = 0")
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_for_user(pool: &SqlitePool, user_id: &str) -> Result<Vec<ApiToken>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ApiToken>(
|
||||||
|
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<ApiToken>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, ApiToken>("SELECT * FROM api_tokens WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE api_tokens SET revoked = 1 WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_last_used(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::db::models::User;
|
||||||
|
|
||||||
|
pub async fn find_by_id(pool: &SqlitePool, id: &str) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_username(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = ?")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<User>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>("SELECT * FROM users WHERE tenant_id = ? ORDER BY created_at DESC")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
tenant_id: &str,
|
||||||
|
username: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<User, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO users (id, tenant_id, username, password_hash, status)
|
||||||
|
VALUES (?, ?, ?, ?, 1)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(password_hash)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_status(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
status: i32,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(status)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_password_hash(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
password_hash: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET password_hash = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(password_hash)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_last_login(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
id: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET last_login_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn username_exists(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
tenant_id: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let row: (i64,) =
|
||||||
|
sqlx::query_as("SELECT COUNT(*) FROM users WHERE tenant_id = ? AND username = ?")
|
||||||
|
.bind(tenant_id)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.0 > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count users that have the admin role.
|
||||||
|
pub async fn count_admins(pool: &SqlitePool) -> Result<i64, sqlx::Error> {
|
||||||
|
let row: (i64,) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT COUNT(DISTINCT ur.user_id)
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Central application error type.
|
||||||
|
/// All handlers return `Result<T, AppError>`, which Axum maps to HTTP responses.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Database(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("resource not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
#[error("invalid credentials")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("insufficient permissions")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("conflict: {0}")]
|
||||||
|
Conflict(String),
|
||||||
|
|
||||||
|
#[error("invalid input: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
|
||||||
|
#[error("too many requests")]
|
||||||
|
RateLimited,
|
||||||
|
|
||||||
|
#[error("internal error")]
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, code) = match &self {
|
||||||
|
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||||
|
AppError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
|
||||||
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized"),
|
||||||
|
AppError::Forbidden => (StatusCode::FORBIDDEN, "forbidden"),
|
||||||
|
AppError::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
|
||||||
|
AppError::InvalidInput(_) => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_input"),
|
||||||
|
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited"),
|
||||||
|
AppError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log server-side errors for visibility
|
||||||
|
match &self {
|
||||||
|
AppError::Database(e) => {
|
||||||
|
tracing::error!(error = %e, "database error");
|
||||||
|
}
|
||||||
|
AppError::Internal => {
|
||||||
|
tracing::error!("internal error");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = json!({
|
||||||
|
"error": self.to_string(),
|
||||||
|
"code": code,
|
||||||
|
});
|
||||||
|
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience alias used throughout the codebase.
|
||||||
|
pub type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_status_mapping() {
|
||||||
|
let err_not_found = AppError::NotFound;
|
||||||
|
let resp = err_not_found.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let err_unauthorized = AppError::Unauthorized;
|
||||||
|
let resp = err_unauthorized.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let err_forbidden = AppError::Forbidden;
|
||||||
|
let resp = err_forbidden.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let err_conflict = AppError::Conflict("already exists".into());
|
||||||
|
let resp = err_conflict.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CONFLICT);
|
||||||
|
|
||||||
|
let err_invalid = AppError::InvalidInput("bad value".into());
|
||||||
|
let resp = err_invalid.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
|
let err_rate = AppError::RateLimited;
|
||||||
|
let resp = err_rate.into_response();
|
||||||
|
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{models::Application, repository::applications as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
tenant_id: &str,
|
||||||
|
name: &str,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Application, AppError> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
repo::create(pool, &id, tenant_id, name, slug)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<Application>, AppError> {
|
||||||
|
repo::list(pool, tenant_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_slug(pool: &SqlitePool, slug: &str) -> Result<Application, AppError> {
|
||||||
|
repo::find_by_slug(pool, slug)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod applications;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod service_accounts;
|
||||||
|
pub mod users;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{db::repository::permissions as repo, error::AppError};
|
||||||
|
|
||||||
|
/// Return all permission names held by a user.
|
||||||
|
pub async fn list_user_permissions(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Vec<String>, AppError> {
|
||||||
|
repo::list_for_user(pool, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the user holds the given named permission.
|
||||||
|
pub async fn has_permission(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
permission: &str,
|
||||||
|
) -> Result<bool, AppError> {
|
||||||
|
repo::user_has_permission(pool, user_id, permission)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce that a user holds a permission, returning `Forbidden` otherwise.
|
||||||
|
pub async fn require_permission(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
permission: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if has_permission(pool, user_id, permission).await? {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(AppError::Forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{models::Role, repository::roles as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Assign a named role to a user. No-ops if already assigned.
|
||||||
|
pub async fn assign_role(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
role_name: &str,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let role = repo::find_by_name(pool, role_name)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::assign_to_user(&mut tx, user_id, &role.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({ "role": role_name }).to_string();
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(user_id),
|
||||||
|
action: "role_assigned",
|
||||||
|
resource_type: "role",
|
||||||
|
resource_id: Some(&role.id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: Some(&metadata),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user_id, role = %role_name, "role assigned");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a named role from a user. No-ops if not assigned.
|
||||||
|
pub async fn remove_role(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
role_name: &str,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let role = repo::find_by_name(pool, role_name)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::remove_from_user(&mut tx, user_id, &role.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({ "role": role_name }).to_string();
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(user_id),
|
||||||
|
action: "role_removed",
|
||||||
|
resource_type: "role",
|
||||||
|
resource_id: Some(&role.id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: Some(&metadata),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user_id, role = %role_name, "role removed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all roles defined in the system.
|
||||||
|
pub async fn list_roles(pool: &SqlitePool) -> Result<Vec<Role>, AppError> {
|
||||||
|
repo::list_all(pool).await.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List roles held by a specific user.
|
||||||
|
pub async fn list_user_roles(pool: &SqlitePool, user_id: &str) -> Result<Vec<Role>, AppError> {
|
||||||
|
repo::list_for_user(pool, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::{models::ServiceAccount, repository::service_accounts as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
tenant_id: &str,
|
||||||
|
name: &str,
|
||||||
|
description: Option<&str>,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<ServiceAccount, AppError> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let sa = repo::create(&mut tx, &id, tenant_id, name, description)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: None,
|
||||||
|
action: "service_account_created",
|
||||||
|
resource_type: "service_account",
|
||||||
|
resource_id: Some(&sa.id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
Ok(sa)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<ServiceAccount>, AppError> {
|
||||||
|
repo::list(pool, tenant_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_enabled(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::set_enabled(&mut tx, id, enabled)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let action = if enabled {
|
||||||
|
"service_account_enabled"
|
||||||
|
} else {
|
||||||
|
"service_account_disabled"
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: None,
|
||||||
|
action,
|
||||||
|
resource_type: "service_account",
|
||||||
|
resource_id: Some(id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Warning,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::SecurityConfig,
|
||||||
|
db::{models::User, repository::users as repo},
|
||||||
|
error::AppError,
|
||||||
|
security::passwords,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Create a new user account in the given tenant.
|
||||||
|
///
|
||||||
|
/// Fails with `Conflict` if the username is already taken.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub async fn create_user(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
cfg: &SecurityConfig,
|
||||||
|
tenant_id: &str,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<User, AppError> {
|
||||||
|
if username.trim().is_empty() {
|
||||||
|
return Err(AppError::InvalidInput("username cannot be empty".into()));
|
||||||
|
}
|
||||||
|
passwords::validate_password_strength(password, false)?;
|
||||||
|
|
||||||
|
if repo::username_exists(pool, tenant_id, username)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
{
|
||||||
|
return Err(AppError::Conflict(format!(
|
||||||
|
"username '{username}' is already taken"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let hash = passwords::hash_password(password, cfg)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let user = repo::create(&mut tx, &id, tenant_id, username, &hash)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(&user.id),
|
||||||
|
action: "user_created",
|
||||||
|
resource_type: "user",
|
||||||
|
resource_id: Some(&user.id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user.id, username = %username, "user created");
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a user by ID.
|
||||||
|
pub async fn get_user(pool: &SqlitePool, id: &str) -> Result<User, AppError> {
|
||||||
|
repo::find_by_id(pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve a user by username.
|
||||||
|
pub async fn get_user_by_username(pool: &SqlitePool, username: &str) -> Result<User, AppError> {
|
||||||
|
repo::find_by_username(pool, username)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all users in a tenant.
|
||||||
|
pub async fn list_users(pool: &SqlitePool, tenant_id: &str) -> Result<Vec<User>, AppError> {
|
||||||
|
repo::list(pool, tenant_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a user's status (Active=1, Disabled=2, Locked=3).
|
||||||
|
pub async fn update_status(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
status: i32,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// Verify user exists first
|
||||||
|
let _user = get_user(pool, user_id).await?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::update_status(&mut tx, user_id, status)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let action = match status {
|
||||||
|
1 => "user_enabled",
|
||||||
|
2 => "user_disabled",
|
||||||
|
3 => "user_locked",
|
||||||
|
_ => "user_updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
let severity = match status {
|
||||||
|
1 => crate::db::models::AuditSeverity::Info,
|
||||||
|
_ => crate::db::models::AuditSeverity::Warning,
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(user_id),
|
||||||
|
action,
|
||||||
|
resource_type: "user",
|
||||||
|
resource_id: Some(user_id),
|
||||||
|
severity,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
tracing::info!(user_id = %user_id, status = %status, "user status updated");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset a user's password.
|
||||||
|
pub async fn reset_password(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
cfg: &SecurityConfig,
|
||||||
|
user_id: &str,
|
||||||
|
new_password: &str,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let user = get_user(pool, user_id).await?;
|
||||||
|
let user_roles = crate::db::repository::roles::list_for_user(pool, &user.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
let is_admin = user_roles.iter().any(|r| r.name == "admin");
|
||||||
|
passwords::validate_password_strength(new_password, is_admin)?;
|
||||||
|
let hash = passwords::hash_password(new_password, cfg)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::update_password_hash(&mut tx, user_id, &hash)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(user_id),
|
||||||
|
action: "password_reset",
|
||||||
|
resource_type: "user",
|
||||||
|
resource_id: Some(user_id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Warning,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
tracing::info!(user_id = %user_id, "password reset");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod audit;
|
||||||
|
pub mod cli;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod error;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod security;
|
||||||
|
pub mod state;
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
use nx9_auth::{
|
||||||
|
api,
|
||||||
|
cli::{self, Cli, Commands},
|
||||||
|
config::Config,
|
||||||
|
db,
|
||||||
|
db::repository::sessions as session_repo,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
// Parse CLI arguments first (before any logging so --help works cleanly)
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Initialize logging based on the command and verbosity
|
||||||
|
let is_serve = matches!(cli.command, Commands::Serve);
|
||||||
|
if is_serve {
|
||||||
|
// Structured JSON logging for production server deployment
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "nx9_auth=info,tower_http=info".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.with(fmt::layer().json())
|
||||||
|
.init();
|
||||||
|
} else if cli.verbose {
|
||||||
|
// Human-readable compact logging for verbosity in subcommands
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "nx9_auth=debug".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.with(fmt::layer().compact())
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
// Silence info/debug logging for clean operator CLI commands
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "nx9_auth=warn".parse().unwrap()),
|
||||||
|
)
|
||||||
|
.with(fmt::layer().compact())
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config_opt = if matches!(
|
||||||
|
cli.command,
|
||||||
|
Commands::Init { .. } | Commands::ConfigPath { .. }
|
||||||
|
) {
|
||||||
|
// For init/config-path commands, a missing override config is fine
|
||||||
|
if let Some(ref path) = cli.config {
|
||||||
|
if path.exists() {
|
||||||
|
Some(Config::load(path)?)
|
||||||
|
} else {
|
||||||
|
let mut cfg = Config {
|
||||||
|
config_path: Some(path.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
cfg.resolve_paths();
|
||||||
|
Some(cfg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Config::find_and_load(None)?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Config::find_and_load(cli.config.as_deref())?
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = match config_opt {
|
||||||
|
Some(cfg) => cfg,
|
||||||
|
None => {
|
||||||
|
// init and config-path are allowed to run without an existing config file.
|
||||||
|
// We use default Config structure for them.
|
||||||
|
if matches!(
|
||||||
|
cli.command,
|
||||||
|
Commands::Init { .. } | Commands::ConfigPath { .. }
|
||||||
|
) {
|
||||||
|
let mut cfg = Config::default();
|
||||||
|
cfg.resolve_paths();
|
||||||
|
cfg
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
"\nError: No configuration found.\n\nRun:\n\n nx9-auth init\n\nOr if running in Docker:\n\n docker exec -it nx9-auth nx9-auth init\n"
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
version = env!("CARGO_PKG_VERSION"),
|
||||||
|
git_commit = env!("GIT_COMMIT"),
|
||||||
|
"nx9-auth starting"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch to serve or CLI command
|
||||||
|
match cli.command {
|
||||||
|
Commands::Serve => run_server(config).await,
|
||||||
|
cmd => cli::run(cmd, config).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the HTTP server (Milestone B+).
|
||||||
|
async fn run_server(config: Config) -> anyhow::Result<()> {
|
||||||
|
// Open DB pool and run migrations
|
||||||
|
let pool = db::create_pool(&config.database.path).await?;
|
||||||
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
|
// Cleanup expired sessions at startup (one-shot, fire-and-forget)
|
||||||
|
let pool_clone = pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match session_repo::cleanup_expired(&pool_clone).await {
|
||||||
|
Ok(n) => tracing::info!(removed = n, "expired sessions cleaned up"),
|
||||||
|
Err(e) => tracing::warn!(error = %e, "session cleanup failed"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build application state
|
||||||
|
let state = AppState::new(pool, config.clone());
|
||||||
|
|
||||||
|
// Build router
|
||||||
|
let app = api::router::build(state);
|
||||||
|
|
||||||
|
// Bind and serve
|
||||||
|
let addr: SocketAddr = format!("{}:{}", config.server.host, config.server.port)
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid bind address: {}", e))?;
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
address = %addr,
|
||||||
|
"server listening"
|
||||||
|
);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\nServer listening on:\n\n http://{}\n\nHealth:\n\n http://{}/health\n",
|
||||||
|
addr, addr
|
||||||
|
);
|
||||||
|
|
||||||
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{ConnectInfo, FromRequestParts},
|
||||||
|
http::request::Parts,
|
||||||
|
};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
/// Request context for audit logging — captures IP and User-Agent.
|
||||||
|
///
|
||||||
|
/// Handlers include this extractor to forward client metadata to the audit log
|
||||||
|
/// without threading raw request headers through the call stack.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuditContext {
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
pub user_agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuditContext
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = std::convert::Infallible;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
// Prefer X-Forwarded-For (set by reverse proxies like Nginx)
|
||||||
|
let ip_address = parts
|
||||||
|
.headers
|
||||||
|
.get("x-forwarded-for")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(',').next())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.or_else(|| {
|
||||||
|
// Fall back to direct peer address (requires ConnectInfo extension)
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|ci| ci.0.ip().to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_agent = parts
|
||||||
|
.headers
|
||||||
|
.get(axum::http::header::USER_AGENT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
Ok(AuditContext {
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{FromRef, FromRequestParts},
|
||||||
|
http::request::Parts,
|
||||||
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::models::User,
|
||||||
|
db::repository::users as user_repo,
|
||||||
|
error::AppError,
|
||||||
|
security::{sessions, tokens},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Describes how the current request was authenticated.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
Session,
|
||||||
|
Token,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum extractor that resolves the authenticated user from either a session
|
||||||
|
/// cookie or a Bearer token in the Authorization header.
|
||||||
|
///
|
||||||
|
/// Handlers that need an authenticated user simply include `auth: AuthUser`
|
||||||
|
/// in their parameter list.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub user: User,
|
||||||
|
pub method: AuthMethod,
|
||||||
|
/// Session ID — populated when `method == Session`, used for logout.
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> FromRequestParts<S> for AuthUser
|
||||||
|
where
|
||||||
|
AppState: FromRef<S>,
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, AppError> {
|
||||||
|
let app_state = AppState::from_ref(state);
|
||||||
|
|
||||||
|
// 1. Try session cookie first
|
||||||
|
let jar = CookieJar::from_headers(&parts.headers);
|
||||||
|
if let Some(cookie) = jar.get(sessions::SESSION_COOKIE) {
|
||||||
|
let raw = cookie.value();
|
||||||
|
if let Some(session) =
|
||||||
|
sessions::validate_session(&app_state.pool, raw, &app_state.config.security).await?
|
||||||
|
{
|
||||||
|
let user = user_repo::find_by_id(&app_state.pool, &session.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
if !user.is_active() {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(AuthUser {
|
||||||
|
user,
|
||||||
|
method: AuthMethod::Session,
|
||||||
|
session_id: Some(session.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try Bearer token in Authorization header
|
||||||
|
if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
|
||||||
|
if let Ok(value) = auth_header.to_str() {
|
||||||
|
if let Some(raw) = value.strip_prefix("Bearer ") {
|
||||||
|
if let Some(token) = tokens::validate_token(&app_state.pool, raw.trim()).await?
|
||||||
|
{
|
||||||
|
let user = user_repo::find_by_id(&app_state.pool, &token.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
|
if !user.is_active() {
|
||||||
|
return Err(AppError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(AuthUser {
|
||||||
|
user,
|
||||||
|
method: AuthMethod::Token,
|
||||||
|
session_id: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AppError::Unauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod audit;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod permissions;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{error::AppError, identity::permissions};
|
||||||
|
|
||||||
|
/// Enforce that the calling user has the given permission.
|
||||||
|
///
|
||||||
|
/// Alias for `permissions::require_permission` — imported in handlers for
|
||||||
|
/// readability: `require(pool, user_id, "users:create").await?`
|
||||||
|
#[inline]
|
||||||
|
pub async fn require(pool: &SqlitePool, user_id: &str, permission: &str) -> Result<(), AppError> {
|
||||||
|
permissions::require_permission(pool, user_id, permission).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod passwords;
|
||||||
|
pub mod rate_limit;
|
||||||
|
pub mod sessions;
|
||||||
|
pub mod tokens;
|
||||||
|
|
||||||
|
pub use rate_limit::RateLimiter;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2, Params,
|
||||||
|
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{config::SecurityConfig, error::AppError};
|
||||||
|
|
||||||
|
/// Hash a plaintext password using Argon2id with configurable cost parameters.
|
||||||
|
///
|
||||||
|
/// Returns a PHC-format string (e.g. `$argon2id$v=19$...`) that includes the
|
||||||
|
/// salt and all parameters. This string is safe to store directly in the DB.
|
||||||
|
pub fn hash_password(password: &str, cfg: &SecurityConfig) -> Result<String, AppError> {
|
||||||
|
let params = Argon2::new(
|
||||||
|
argon2::Algorithm::Argon2id,
|
||||||
|
argon2::Version::V0x13,
|
||||||
|
Params::new(
|
||||||
|
cfg.argon2_memory,
|
||||||
|
cfg.argon2_iterations,
|
||||||
|
cfg.argon2_parallelism,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "invalid argon2 params");
|
||||||
|
AppError::Internal
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let hash = params
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "argon2 hashing failed");
|
||||||
|
AppError::Internal
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(hash.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext password against a stored Argon2id PHC hash.
|
||||||
|
///
|
||||||
|
/// Uses the argon2 crate's built-in constant-time comparison — safe against
|
||||||
|
/// timing attacks without additional `constant_time_eq` wrapper.
|
||||||
|
pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
|
||||||
|
let parsed = PasswordHash::new(hash).map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "failed to parse password hash");
|
||||||
|
AppError::Internal
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match Argon2::default().verify_password(password.as_bytes(), &parsed) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(argon2::password_hash::Error::Password) => Ok(false),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "argon2 verification error");
|
||||||
|
Err(AppError::Internal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a dummy Argon2id hash with the currently configured parameters.
|
||||||
|
///
|
||||||
|
/// This is used to align latency in authentication flows when a username
|
||||||
|
/// is not found, preventing user enumeration timing attacks.
|
||||||
|
pub fn verify_dummy(cfg: &SecurityConfig) -> Result<(), AppError> {
|
||||||
|
let _ = hash_password("dummy_password_for_timing_attacks", cfg)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate password strength against common patterns and minimum length.
|
||||||
|
///
|
||||||
|
/// For admin accounts (is_admin = true), enforces 12-char minimum.
|
||||||
|
/// For standard accounts, enforces 8-char minimum.
|
||||||
|
/// Both reject common passwords like "password", "admin123", "qwerty", "12345678".
|
||||||
|
pub fn validate_password_strength(password: &str, is_admin: bool) -> Result<(), AppError> {
|
||||||
|
let min_len = if is_admin { 12 } else { 8 };
|
||||||
|
if password.len() < min_len {
|
||||||
|
return Err(AppError::InvalidInput(format!(
|
||||||
|
"password must be at least {min_len} characters long"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = password.to_lowercase();
|
||||||
|
let weak_list = [
|
||||||
|
"password",
|
||||||
|
"admin123",
|
||||||
|
"qwerty",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"administrator",
|
||||||
|
"nx9-auth",
|
||||||
|
"nx9auth",
|
||||||
|
];
|
||||||
|
|
||||||
|
for weak in &weak_list {
|
||||||
|
if normalized.contains(weak) {
|
||||||
|
return Err(AppError::InvalidInput(
|
||||||
|
"password contains a weak or common sequence".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::SecurityConfig;
|
||||||
|
|
||||||
|
fn test_cfg() -> SecurityConfig {
|
||||||
|
SecurityConfig {
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
session_absolute_ttl_days: 30,
|
||||||
|
token_ttl_days: 365,
|
||||||
|
argon2_memory: 4096, // low cost for tests
|
||||||
|
argon2_iterations: 1,
|
||||||
|
argon2_parallelism: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hash_and_verify() {
|
||||||
|
let cfg = test_cfg();
|
||||||
|
let pass = "correct_password_123";
|
||||||
|
let hash = hash_password(pass, &cfg).unwrap();
|
||||||
|
assert!(verify_password(pass, &hash).unwrap());
|
||||||
|
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strength_validation() {
|
||||||
|
// Standard user length
|
||||||
|
assert!(validate_password_strength("super_secure_passphrase_123", false).is_ok());
|
||||||
|
assert!(validate_password_strength("short", false).is_err());
|
||||||
|
|
||||||
|
// Admin length
|
||||||
|
assert!(validate_password_strength("super_secure_admin_passphrase_123", true).is_ok());
|
||||||
|
assert!(validate_password_strength("short_admin", true).is_err());
|
||||||
|
|
||||||
|
// Weak password checks
|
||||||
|
assert!(validate_password_strength("my-password-is-weak", false).is_err());
|
||||||
|
assert!(validate_password_strength("admin1234567", false).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use dashmap::DashMap;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Per-IP tracking state.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct IpState {
|
||||||
|
/// Failure timestamps within the current window.
|
||||||
|
window: VecDeque<Instant>,
|
||||||
|
/// Number of times this IP has been locked out (escalation counter).
|
||||||
|
lockout_count: u32,
|
||||||
|
/// When the current lockout expires. `None` if not locked.
|
||||||
|
locked_until: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IpState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
window: VecDeque::new(),
|
||||||
|
lockout_count: 0,
|
||||||
|
locked_until: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory escalating rate limiter for login attempts.
|
||||||
|
///
|
||||||
|
/// Policy:
|
||||||
|
/// - Track failures per IP in a 15-minute sliding window.
|
||||||
|
/// - After 5 failures → lock for 15 minutes (level 1).
|
||||||
|
/// - After another 5 failures post-unlock → lock for 1 hour (level 2).
|
||||||
|
/// - After another 5 failures post-unlock → lock for 24 hours (level 3+).
|
||||||
|
///
|
||||||
|
/// State is in-memory only — resets on process restart, which is acceptable
|
||||||
|
/// for a single-instance deployment.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RateLimiter {
|
||||||
|
state: DashMap<IpAddr, IpState>,
|
||||||
|
/// Window for failure counting.
|
||||||
|
window: Duration,
|
||||||
|
/// Max failures per window before lockout.
|
||||||
|
max_failures: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
state: DashMap::new(),
|
||||||
|
window: Duration::from_secs(15 * 60),
|
||||||
|
max_failures: 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate lockout duration based on escalation level.
|
||||||
|
fn lockout_duration(level: u32) -> Duration {
|
||||||
|
match level {
|
||||||
|
1 => Duration::from_secs(15 * 60), // 15 minutes
|
||||||
|
2 => Duration::from_secs(60 * 60), // 1 hour
|
||||||
|
_ => Duration::from_secs(24 * 60 * 60), // 24 hours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the given IP is currently allowed to attempt a login.
|
||||||
|
///
|
||||||
|
/// Returns `Err(AppError::RateLimited)` if the IP is locked out.
|
||||||
|
pub fn check(&self, ip: IpAddr) -> Result<(), AppError> {
|
||||||
|
let state = self.state.get(&ip);
|
||||||
|
if let Some(s) = state {
|
||||||
|
if let Some(until) = s.locked_until {
|
||||||
|
if Instant::now() < until {
|
||||||
|
return Err(AppError::RateLimited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a failed login attempt for an IP.
|
||||||
|
///
|
||||||
|
/// Triggers lockout if the failure threshold is reached.
|
||||||
|
pub fn record_failure(&self, ip: IpAddr) {
|
||||||
|
let mut s = self.state.entry(ip).or_insert_with(IpState::new);
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// Clear the lockout if it has expired
|
||||||
|
if let Some(until) = s.locked_until {
|
||||||
|
if now >= until {
|
||||||
|
s.locked_until = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune old failures outside the window
|
||||||
|
let cutoff = now - self.window;
|
||||||
|
while s.window.front().is_some_and(|&t| t < cutoff) {
|
||||||
|
s.window.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
s.window.push_back(now);
|
||||||
|
|
||||||
|
if s.window.len() >= self.max_failures as usize {
|
||||||
|
s.lockout_count += 1;
|
||||||
|
let duration = Self::lockout_duration(s.lockout_count);
|
||||||
|
s.locked_until = Some(now + duration);
|
||||||
|
s.window.clear();
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
ip = %ip,
|
||||||
|
lockout_count = s.lockout_count,
|
||||||
|
duration_secs = duration.as_secs(),
|
||||||
|
"login rate limit triggered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a successful login — clear failure history for this IP.
|
||||||
|
pub fn record_success(&self, ip: IpAddr) {
|
||||||
|
if let Some(mut s) = self.state.get_mut(&ip) {
|
||||||
|
s.window.clear();
|
||||||
|
s.locked_until = None;
|
||||||
|
// Do NOT reset lockout_count — escalation persists across successful logins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: DashMap::new(),
|
||||||
|
window: Duration::from_secs(15 * 60),
|
||||||
|
max_failures: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rate_limiter() {
|
||||||
|
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
|
||||||
|
let limiter = RateLimiter {
|
||||||
|
state: DashMap::new(),
|
||||||
|
window: Duration::from_secs(60),
|
||||||
|
max_failures: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initially OK
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
|
||||||
|
// First failure
|
||||||
|
limiter.record_failure(ip);
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
|
||||||
|
// Second failure
|
||||||
|
limiter.record_failure(ip);
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
|
||||||
|
// Third failure -> should trigger lockout
|
||||||
|
limiter.record_failure(ip);
|
||||||
|
assert!(limiter.check(ip).is_err());
|
||||||
|
|
||||||
|
// Clear via success
|
||||||
|
limiter.record_success(ip);
|
||||||
|
assert!(limiter.check(ip).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_lockout_escalation() {
|
||||||
|
assert_eq!(
|
||||||
|
RateLimiter::lockout_duration(1),
|
||||||
|
Duration::from_secs(15 * 60)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RateLimiter::lockout_duration(2),
|
||||||
|
Duration::from_secs(60 * 60)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RateLimiter::lockout_duration(3),
|
||||||
|
Duration::from_secs(24 * 60 * 60)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use rand::RngCore;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::SecurityConfig,
|
||||||
|
db::{models::Session, repository::sessions as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "nx9_session";
|
||||||
|
|
||||||
|
/// Generate a cryptographically random session token (32 bytes → 64 hex chars).
|
||||||
|
pub fn generate_session_token() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a raw session token using BLAKE3 (constant-time, fast).
|
||||||
|
pub fn hash_session_token(raw: &str) -> String {
|
||||||
|
hex::encode(blake3::hash(raw.as_bytes()).as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new session in the database.
|
||||||
|
///
|
||||||
|
/// Returns `(Session row, raw_token)` — the raw token is placed in the cookie
|
||||||
|
/// and never stored. Only the BLAKE3 hash is persisted.
|
||||||
|
pub async fn create_session(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
ip_address: Option<&str>,
|
||||||
|
user_agent: Option<&str>,
|
||||||
|
cfg: &SecurityConfig,
|
||||||
|
) -> Result<(Session, String), AppError> {
|
||||||
|
let raw_token = generate_session_token();
|
||||||
|
let token_hash = hash_session_token(&raw_token);
|
||||||
|
|
||||||
|
// Absolute expiry = now + session_absolute_ttl_days
|
||||||
|
let expires_at =
|
||||||
|
chrono::Utc::now() + chrono::Duration::days(cfg.session_absolute_ttl_days as i64);
|
||||||
|
let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let session = repo::create(
|
||||||
|
pool,
|
||||||
|
&id,
|
||||||
|
user_id,
|
||||||
|
&token_hash,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
&expires_at_str,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
Ok((session, raw_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a raw session token from a cookie.
|
||||||
|
///
|
||||||
|
/// Enforces both absolute TTL and idle timeout. Touches `last_seen_at` on
|
||||||
|
/// every successful validation.
|
||||||
|
pub async fn validate_session(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
raw_token: &str,
|
||||||
|
cfg: &SecurityConfig,
|
||||||
|
) -> Result<Option<Session>, AppError> {
|
||||||
|
let token_hash = hash_session_token(raw_token);
|
||||||
|
|
||||||
|
let session = repo::find_by_token_hash(pool, &token_hash)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let Some(session) = session else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
// Check absolute expiry
|
||||||
|
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||||
|
if now > expires {
|
||||||
|
repo::revoke(pool, &session.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check idle timeout
|
||||||
|
if let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at) {
|
||||||
|
let idle_deadline = last_seen + chrono::Duration::hours(cfg.session_ttl_hours as i64);
|
||||||
|
if now > idle_deadline {
|
||||||
|
repo::revoke(pool, &session.id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch last_seen (fire-and-forget — don't fail the request if this errors)
|
||||||
|
let _ = repo::update_last_seen(pool, &session.id).await;
|
||||||
|
|
||||||
|
Ok(Some(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a session by its ID.
|
||||||
|
pub async fn revoke_session(pool: &SqlitePool, session_id: &str) -> Result<(), AppError> {
|
||||||
|
repo::revoke(pool, session_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_generation_and_hashing() {
|
||||||
|
let t1 = generate_session_token();
|
||||||
|
let t2 = generate_session_token();
|
||||||
|
assert_ne!(t1, t2);
|
||||||
|
assert_eq!(t1.len(), 64);
|
||||||
|
|
||||||
|
let h1 = hash_session_token(&t1);
|
||||||
|
let h2 = hash_session_token(&t1);
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
assert_ne!(h1, t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use rand::RngCore;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
config::SecurityConfig,
|
||||||
|
db::{models::ApiToken, repository::tokens as repo},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Prefix for all personal access tokens.
|
||||||
|
pub const PAT_PREFIX: &str = "nx9_pat_";
|
||||||
|
|
||||||
|
/// Generate a new personal access token string.
|
||||||
|
///
|
||||||
|
/// Format: `nx9_pat_<64 hex chars>` (32 random bytes)
|
||||||
|
pub fn generate_pat() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
|
format!("{}{}", PAT_PREFIX, hex::encode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a raw token string using BLAKE3.
|
||||||
|
pub fn hash_token(raw: &str) -> String {
|
||||||
|
hex::encode(blake3::hash(raw.as_bytes()).as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new personal access token for a user.
|
||||||
|
///
|
||||||
|
/// Returns `(ApiToken row, raw_token)` — the raw token is shown once and
|
||||||
|
/// never stored. Only the BLAKE3 hash is persisted.
|
||||||
|
pub async fn create_token(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
user_id: &str,
|
||||||
|
name: &str,
|
||||||
|
cfg: &SecurityConfig,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(ApiToken, String), AppError> {
|
||||||
|
let raw = generate_pat();
|
||||||
|
let hash = hash_token(&raw);
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let expires_at = chrono::Utc::now() + chrono::Duration::days(cfg.token_ttl_days as i64);
|
||||||
|
let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let token = repo::create(&mut tx, &id, user_id, name, &hash, Some(&expires_at_str))
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({ "token_id": token.id, "name": name }).to_string();
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(user_id),
|
||||||
|
action: "token_created",
|
||||||
|
resource_type: "token",
|
||||||
|
resource_id: Some(&token.id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Info,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: Some(&metadata),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
Ok((token, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a personal access token.
|
||||||
|
pub async fn revoke_token(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
audit_actor_id: Option<&str>,
|
||||||
|
audit_ip: Option<&str>,
|
||||||
|
audit_ua: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let token = repo::find_by_id(pool, id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
repo::revoke(&mut tx, id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let metadata = serde_json::json!({ "token_id": id, "name": token.name }).to_string();
|
||||||
|
crate::audit::log(
|
||||||
|
&mut tx,
|
||||||
|
crate::audit::AuditEvent {
|
||||||
|
actor_id: audit_actor_id,
|
||||||
|
target_id: Some(&token.user_id),
|
||||||
|
action: "token_revoked",
|
||||||
|
resource_type: "token",
|
||||||
|
resource_id: Some(id),
|
||||||
|
severity: crate::db::models::AuditSeverity::Warning,
|
||||||
|
ip: audit_ip,
|
||||||
|
ua: audit_ua,
|
||||||
|
metadata: Some(&metadata),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a raw PAT from an Authorization header.
|
||||||
|
///
|
||||||
|
/// Strips the `nx9_pat_` prefix, hashes it, and looks it up. Returns `None`
|
||||||
|
/// if the token is unknown, revoked, or expired.
|
||||||
|
pub async fn validate_token(pool: &SqlitePool, raw: &str) -> Result<Option<ApiToken>, AppError> {
|
||||||
|
// Must have the expected prefix
|
||||||
|
if !raw.starts_with(PAT_PREFIX) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hash_token(raw);
|
||||||
|
let token = repo::find_by_hash(pool, &hash)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
let Some(token) = token else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check expiry if set
|
||||||
|
if let Some(ref exp) = token.expires_at {
|
||||||
|
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||||
|
if chrono::Utc::now() > expires {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch last_used_at (fire-and-forget)
|
||||||
|
let _ = repo::update_last_used(pool, &token.id).await;
|
||||||
|
|
||||||
|
Ok(Some(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_token_generation_and_prefix() {
|
||||||
|
let t1 = generate_pat();
|
||||||
|
let t2 = generate_pat();
|
||||||
|
assert_ne!(t1, t2);
|
||||||
|
assert!(t1.starts_with(PAT_PREFIX));
|
||||||
|
|
||||||
|
let h1 = hash_token(&t1);
|
||||||
|
let h2 = hash_token(&t1);
|
||||||
|
assert_eq!(h1, h2);
|
||||||
|
assert_ne!(h1, t1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::{config::Config, security::RateLimiter};
|
||||||
|
|
||||||
|
/// Shared application state injected into every Axum handler via `State<AppState>`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub pool: SqlitePool,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub rate_limiter: Arc<RateLimiter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(pool: SqlitePool, config: Config) -> Self {
|
||||||
|
Self {
|
||||||
|
pool,
|
||||||
|
config: Arc::new(config),
|
||||||
|
rate_limiter: RateLimiter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
use nx9_auth::cli::{Commands, run};
|
||||||
|
use nx9_auth::config::Config;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn setup_test_db(db_path: &str) {
|
||||||
|
let _ = fs::remove_file(db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn teardown_test_db(db_path: &str) {
|
||||||
|
let _ = fs::remove_file(db_path);
|
||||||
|
let _ = fs::remove_file(format!("{}-wal", db_path));
|
||||||
|
let _ = fs::remove_file(format!("{}-shm", db_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_path_expansion() {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string());
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = "~/test_subdir/test.db".to_string();
|
||||||
|
config.resolve_paths();
|
||||||
|
|
||||||
|
let expected = Path::new(&home).join("test_subdir/test.db");
|
||||||
|
assert_eq!(config.database.path, expected.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_backup_validation_and_integrity() {
|
||||||
|
let db_path = "test_cli_backup.db";
|
||||||
|
setup_test_db(db_path);
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = db_path.to_string();
|
||||||
|
|
||||||
|
// 1. Initialize DB and run migrations
|
||||||
|
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||||
|
nx9_auth::db::run_migrations(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// 2. Validate backup safety rejects active DB
|
||||||
|
let res = run(
|
||||||
|
Commands::Backup {
|
||||||
|
path: PathBuf::from(db_path),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert!(
|
||||||
|
res.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Backup destination cannot be the active database file")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reject WAL file
|
||||||
|
let wal_path = format!("{}-wal", db_path);
|
||||||
|
let res = run(
|
||||||
|
Commands::Backup {
|
||||||
|
path: PathBuf::from(&wal_path),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert!(
|
||||||
|
res.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Backup destination cannot be the active WAL file")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reject SHM file
|
||||||
|
let shm_path = format!("{}-shm", db_path);
|
||||||
|
let res = run(
|
||||||
|
Commands::Backup {
|
||||||
|
path: PathBuf::from(&shm_path),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert!(
|
||||||
|
res.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Backup destination cannot be the active SHM file")
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Test successful backup
|
||||||
|
let backup_path = "test_cli_backup_dest.db";
|
||||||
|
let _ = fs::remove_file(backup_path);
|
||||||
|
|
||||||
|
let res = run(
|
||||||
|
Commands::Backup {
|
||||||
|
path: PathBuf::from(backup_path),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
assert!(Path::new(backup_path).exists());
|
||||||
|
|
||||||
|
// 4. Verify integrity of the backup database
|
||||||
|
let backup_pool = nx9_auth::db::create_pool(backup_path).await.unwrap();
|
||||||
|
let integrity: (String,) = sqlx::query_as("PRAGMA integrity_check")
|
||||||
|
.fetch_one(&backup_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(integrity.0, "ok");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
teardown_test_db(db_path);
|
||||||
|
teardown_test_db(backup_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cli_config_path_json() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = "test.db".to_string();
|
||||||
|
|
||||||
|
let res = run(Commands::ConfigPath { json: true }, config.clone()).await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
let res = run(Commands::ConfigPath { json: false }, config).await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cli_init_non_interactive() {
|
||||||
|
let db_path = "test_cli_init.db";
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_file(db_path);
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = db_path.to_string();
|
||||||
|
|
||||||
|
// Run init command in non-interactive mode
|
||||||
|
let res = run(
|
||||||
|
Commands::Init {
|
||||||
|
non_interactive: true,
|
||||||
|
skip_admin: false,
|
||||||
|
force: false,
|
||||||
|
admin_user: Some("init_admin".to_string()),
|
||||||
|
admin_password: Some("S3cur3#P@ssw0rd$N0S3qu3nc3!".to_string()),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(ref e) = res {
|
||||||
|
println!("INIT ERROR: {:?}", e);
|
||||||
|
}
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
// Verify DB exists
|
||||||
|
assert!(Path::new(db_path).exists());
|
||||||
|
|
||||||
|
// Verify admin user is created in database
|
||||||
|
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||||
|
let admin_exists = nx9_auth::db::repository::users::username_exists(
|
||||||
|
&pool,
|
||||||
|
nx9_auth::db::models::Tenant::DEFAULT_ID,
|
||||||
|
"init_admin",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(admin_exists);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
teardown_test_db(db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cli_init_skip_admin() {
|
||||||
|
let db_path = "test_cli_init_skip_admin.db";
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_file(db_path);
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = db_path.to_string();
|
||||||
|
|
||||||
|
// Run init command with skip_admin
|
||||||
|
let res = run(
|
||||||
|
Commands::Init {
|
||||||
|
non_interactive: true,
|
||||||
|
skip_admin: true,
|
||||||
|
force: false,
|
||||||
|
admin_user: None,
|
||||||
|
admin_password: None,
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
// Verify DB exists
|
||||||
|
assert!(Path::new(db_path).exists());
|
||||||
|
|
||||||
|
// Verify no admin users exist
|
||||||
|
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||||
|
let admin_count = nx9_auth::db::repository::users::count_admins(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admin_count, 0);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
teardown_test_db(db_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cli_show_user_and_token() {
|
||||||
|
let db_path = "test_cli_show.db";
|
||||||
|
setup_test_db(db_path);
|
||||||
|
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.database.path = db_path.to_string();
|
||||||
|
|
||||||
|
// 1. Init DB and seed user
|
||||||
|
let pool = nx9_auth::db::create_pool(db_path).await.unwrap();
|
||||||
|
nx9_auth::db::run_migrations(&pool).await.unwrap();
|
||||||
|
|
||||||
|
let user = nx9_auth::identity::users::create_user(
|
||||||
|
&pool,
|
||||||
|
&config.security,
|
||||||
|
nx9_auth::db::models::Tenant::DEFAULT_ID,
|
||||||
|
"show_test_user",
|
||||||
|
"S3cur3#P@ssw0rd$N0S3qu3nc3!",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Assign role
|
||||||
|
nx9_auth::identity::roles::assign_role(&pool, &user.id, "viewer", None, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create a token
|
||||||
|
let (token, _raw) = nx9_auth::security::tokens::create_token(
|
||||||
|
&pool,
|
||||||
|
&user.id,
|
||||||
|
"test-token",
|
||||||
|
&config.security,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 2. Run show-user command
|
||||||
|
let res = run(
|
||||||
|
Commands::ShowUser {
|
||||||
|
id_or_username: "show_test_user".to_string(),
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
let res = run(
|
||||||
|
Commands::ShowUser {
|
||||||
|
id_or_username: user.id.clone(),
|
||||||
|
permissions: false,
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
// 3. Run show-token command
|
||||||
|
let res = run(
|
||||||
|
Commands::ShowToken {
|
||||||
|
id: token.id.clone(),
|
||||||
|
},
|
||||||
|
config.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
teardown_test_db(db_path);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,181 @@
|
|||||||
|
use nx9_auth::db::{self, models::Tenant, repository::roles as role_repo};
|
||||||
|
|
||||||
|
async fn setup_test_db() -> (sqlx::SqlitePool, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/migration_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
(pool, db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scenario 1: Fresh Database -> Migrate -> Success
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_migration_scenario_1_fresh() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
|
||||||
|
// Run all migrations
|
||||||
|
let res = db::run_migrations(&pool).await;
|
||||||
|
assert!(res.is_ok(), "Fresh migration failed: {:?}", res);
|
||||||
|
|
||||||
|
// Verify default tables exist
|
||||||
|
for table in &[
|
||||||
|
"tenants",
|
||||||
|
"users",
|
||||||
|
"roles",
|
||||||
|
"permissions",
|
||||||
|
"sessions",
|
||||||
|
"audit_logs",
|
||||||
|
"_sqlx_migrations",
|
||||||
|
] {
|
||||||
|
let exists: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists.is_some(), "Table '{}' was not created", table);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify default tenant and admin/viewer roles exist
|
||||||
|
let tenant_exists = sqlx::query("SELECT 1 FROM tenants WHERE id = ?")
|
||||||
|
.bind(Tenant::DEFAULT_ID)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some();
|
||||||
|
assert!(tenant_exists);
|
||||||
|
|
||||||
|
let admin_role = role_repo::find_by_name(&pool, "admin").await.unwrap();
|
||||||
|
assert!(admin_role.is_some());
|
||||||
|
|
||||||
|
let viewer_role = role_repo::find_by_name(&pool, "viewer").await.unwrap();
|
||||||
|
assert!(viewer_role.is_some());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scenario 2: Database at migration N -> Migrate to N+1 -> Success
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_migration_scenario_2_incremental() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
|
||||||
|
let migrator = sqlx::migrate!("src/db/migrations");
|
||||||
|
let all_migrations = &migrator.migrations;
|
||||||
|
assert!(
|
||||||
|
all_migrations.len() >= 3,
|
||||||
|
"Expected at least 3 migrations to test incremental scenario"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Manually create the _sqlx_migrations table
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE _sqlx_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
checksum BLOB NOT NULL,
|
||||||
|
execution_time INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 2. Manually apply the first 2 migrations (N = 2)
|
||||||
|
for migration in all_migrations.iter().take(2) {
|
||||||
|
let sql: &'static str = Box::leak(migration.sql.as_ref().to_string().into_boxed_str());
|
||||||
|
// Run SQL query directly
|
||||||
|
sqlx::query(sql).execute(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Record it in _sqlx_migrations so SQLx knows it is applied
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time)
|
||||||
|
VALUES (?, ?, 1, ?, 0)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(migration.version)
|
||||||
|
.bind(migration.description.as_ref())
|
||||||
|
.bind(migration.checksum.as_ref())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Now run the SQLx Migrator to migrate to N+1 (and all remaining ones)
|
||||||
|
let res = migrator.run(&pool).await;
|
||||||
|
assert!(res.is_ok(), "Incremental migration failed: {:?}", res);
|
||||||
|
|
||||||
|
// Verify all tables are successfully created
|
||||||
|
for table in &["tenants", "users", "roles", "permissions"] {
|
||||||
|
let exists: Option<(String,)> =
|
||||||
|
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
||||||
|
.bind(table)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
exists.is_some(),
|
||||||
|
"Table '{}' was not created incrementally",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scenario 3: Run Migrations Twice -> Idempotent
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_migration_scenario_3_idempotence() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
|
||||||
|
// First run
|
||||||
|
let res1 = db::run_migrations(&pool).await;
|
||||||
|
assert!(res1.is_ok());
|
||||||
|
|
||||||
|
// Second run
|
||||||
|
let res2 = db::run_migrations(&pool).await;
|
||||||
|
assert!(
|
||||||
|
res2.is_ok(),
|
||||||
|
"Second migration run failed (idempotency issue): {:?}",
|
||||||
|
res2
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify default tenant and roles count didn't duplicate
|
||||||
|
let tenant_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tenants WHERE id = ?")
|
||||||
|
.bind(Tenant::DEFAULT_ID)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tenant_count.0, 1, "Default tenant was duplicated!");
|
||||||
|
|
||||||
|
let admin_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'admin'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(admin_count.0, 1, "Admin role was duplicated!");
|
||||||
|
|
||||||
|
let viewer_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roles WHERE name = 'viewer'")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(viewer_count.0, 1, "Viewer role was duplicated!");
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode, header},
|
||||||
|
};
|
||||||
|
use nx9_auth::{
|
||||||
|
api,
|
||||||
|
config::{Config, SecurityConfig},
|
||||||
|
db::{
|
||||||
|
self,
|
||||||
|
models::Tenant,
|
||||||
|
repository::{roles as role_repo, tokens as token_repo, users as user_repo},
|
||||||
|
},
|
||||||
|
identity::{roles as identity_roles, users as identity_users},
|
||||||
|
security::{passwords, sessions, tokens},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn setup_test_db() -> (sqlx::SqlitePool, String) {
|
||||||
|
let db_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let db_path = format!("target/security_{}.db", db_id);
|
||||||
|
let pool = db::create_pool(&db_path)
|
||||||
|
.await
|
||||||
|
.expect("Failed to create test pool");
|
||||||
|
db::run_migrations(&pool)
|
||||||
|
.await
|
||||||
|
.expect("Failed to run test migrations");
|
||||||
|
(pool, db_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn teardown_test_db(path: String) {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_security_config() -> SecurityConfig {
|
||||||
|
SecurityConfig {
|
||||||
|
session_ttl_hours: 24,
|
||||||
|
session_absolute_ttl_days: 30,
|
||||||
|
token_ttl_days: 365,
|
||||||
|
argon2_memory: 4096, // low cost for fast tests
|
||||||
|
argon2_iterations: 1,
|
||||||
|
argon2_parallelism: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_config(db_path: String) -> Config {
|
||||||
|
Config {
|
||||||
|
server: nx9_auth::config::ServerConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 8656,
|
||||||
|
},
|
||||||
|
database: nx9_auth::config::DatabaseConfig { path: db_path },
|
||||||
|
security: test_security_config(),
|
||||||
|
audit: nx9_auth::config::AuditConfig { enabled: true },
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. Password & Token Leakage Verification
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_no_plaintext_passwords_in_db() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let password = "super_secret_special_pass_123456";
|
||||||
|
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"leak_test_user",
|
||||||
|
password,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Query the raw database row and verify the plaintext password is not in the row
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT password_hash FROM users WHERE id = ?")
|
||||||
|
.bind(&user.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!row.0.contains(password));
|
||||||
|
assert_ne!(row.0, password);
|
||||||
|
|
||||||
|
// Grep/search the entire users table for the plaintext password string
|
||||||
|
let matches: Vec<(String,)> =
|
||||||
|
sqlx::query_as("SELECT id FROM users WHERE password_hash LIKE ? OR username LIKE ?")
|
||||||
|
.bind(format!("%{}%", password))
|
||||||
|
.bind(format!("%{}%", password))
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches.is_empty());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_no_plaintext_tokens_in_db() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"token_leak_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (token, raw_pat) =
|
||||||
|
tokens::create_token(&pool, &user.id, "my_pat", &sec_cfg, None, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Check token_hash in db
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT token_hash FROM api_tokens WHERE id = ?")
|
||||||
|
.bind(&token.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!raw_pat.is_empty());
|
||||||
|
assert!(!row.0.contains(&raw_pat));
|
||||||
|
assert_ne!(row.0, raw_pat);
|
||||||
|
|
||||||
|
// Search table
|
||||||
|
let matches: Vec<(String,)> =
|
||||||
|
sqlx::query_as("SELECT id FROM api_tokens WHERE token_hash LIKE ? OR name LIKE ?")
|
||||||
|
.bind(format!("%{}%", raw_pat))
|
||||||
|
.bind(format!("%{}%", raw_pat))
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches.is_empty());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_no_plaintext_sessions_in_db() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"session_leak_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (session, raw_token) =
|
||||||
|
sessions::create_session(&pool, &user.id, Some("127.0.0.1"), Some("UA"), &sec_cfg)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let row: (String,) = sqlx::query_as("SELECT token_hash FROM sessions WHERE id = ?")
|
||||||
|
.bind(&session.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!raw_token.is_empty());
|
||||||
|
assert!(!row.0.contains(&raw_token));
|
||||||
|
assert_ne!(row.0, raw_token);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. User Enumeration Protection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_user_enumeration_payload_match() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let config = test_config(db_path.clone());
|
||||||
|
let state = AppState::new(pool.clone(), config);
|
||||||
|
let app = api::router::build(state);
|
||||||
|
|
||||||
|
// Scenario A: Non-existent user
|
||||||
|
let req_non_existent = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"username": "non_existent_user_123", "password": "some_random_password"}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let res_non_existent = app.clone().oneshot(req_non_existent).await.unwrap();
|
||||||
|
assert_eq!(res_non_existent.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let body_bytes = axum::body::to_bytes(res_non_existent.into_body(), 2048)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json_non_existent: Value = serde_json::from_slice(&body_bytes).unwrap();
|
||||||
|
|
||||||
|
// Scenario B: Existent user, wrong password
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let _user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"existent_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req_wrong_password = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"username": "existent_user", "password": "wrong_password_abc"}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let res_wrong_password = app.oneshot(req_wrong_password).await.unwrap();
|
||||||
|
assert_eq!(res_wrong_password.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
let body_bytes_wrong = axum::body::to_bytes(res_wrong_password.into_body(), 2048)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json_wrong_password: Value = serde_json::from_slice(&body_bytes_wrong).unwrap();
|
||||||
|
|
||||||
|
// Compare JSON outputs and check format
|
||||||
|
let expected = serde_json::json!({
|
||||||
|
"error": "invalid credentials",
|
||||||
|
"code": "unauthorized"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(json_non_existent, expected);
|
||||||
|
assert_eq!(json_wrong_password, expected);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. Session Revocation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_session_revocation_lifecycle() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let config = test_config(db_path.clone());
|
||||||
|
let state = AppState::new(pool.clone(), config);
|
||||||
|
let app = api::router::build(state);
|
||||||
|
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let _user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"session_lifecycle_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 1. Login to get cookie
|
||||||
|
let req_login = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/login")
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"username": "session_lifecycle_user", "password": "super_secure_passphrase_123"}"#,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let res_login = app.clone().oneshot(req_login).await.unwrap();
|
||||||
|
assert_eq!(res_login.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let cookie_header = res_login
|
||||||
|
.headers()
|
||||||
|
.get(header::SET_COOKIE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
let cookie_value = cookie_header.split(';').next().unwrap(); // e.g. nx9_session=abc...
|
||||||
|
|
||||||
|
// 2. Validate GET /api/v1/auth/me works
|
||||||
|
let req_me = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/auth/me")
|
||||||
|
.header(header::COOKIE, cookie_value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res_me = app.clone().oneshot(req_me).await.unwrap();
|
||||||
|
assert_eq!(res_me.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 3. Logout to revoke session
|
||||||
|
let req_logout = Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/v1/auth/logout")
|
||||||
|
.header(header::COOKIE, cookie_value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res_logout = app.clone().oneshot(req_logout).await.unwrap();
|
||||||
|
assert_eq!(res_logout.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// 4. Try reuse session cookie -> must get 401
|
||||||
|
let req_me_revoked = Request::builder()
|
||||||
|
.method("GET")
|
||||||
|
.uri("/api/v1/auth/me")
|
||||||
|
.header(header::COOKIE, cookie_value)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
let res_me_revoked = app.oneshot(req_me_revoked).await.unwrap();
|
||||||
|
assert_eq!(res_me_revoked.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Transaction Rollback Verification
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_transaction_rollback_on_audit_failure_create_user() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
|
||||||
|
// Trigger Foreign Key constraint violation by passing non-existent audit actor ID
|
||||||
|
let bad_actor_id = "non_existent_user_id_trigger_rollback";
|
||||||
|
let res = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"rollback_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
Some(bad_actor_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Must return Database/Constraint error
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
// Verify user was NOT created in the database due to transaction rollback
|
||||||
|
let user_in_db = user_repo::find_by_username(&pool, "rollback_user")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(user_in_db.is_none());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_transaction_rollback_on_audit_failure_reset_password() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
|
||||||
|
// Create user successfully
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"rollback_pwd_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let original_hash = user.password_hash.clone();
|
||||||
|
|
||||||
|
// Try resetting password but with a bad audit actor id to trigger FK violation
|
||||||
|
let bad_actor_id = "non_existent_actor_id";
|
||||||
|
let res = identity_users::reset_password(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
&user.id,
|
||||||
|
"new_super_secure_passphrase_123456",
|
||||||
|
Some(bad_actor_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
// Verify password hash in db is still the original one (rolled back)
|
||||||
|
let user_after = user_repo::find_by_id(&pool, &user.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(user_after.password_hash, original_hash);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_transaction_rollback_on_audit_failure_assign_role() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"rollback_role_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Try to assign admin role but fail on audit step
|
||||||
|
let bad_actor_id = "non_existent_actor_id";
|
||||||
|
let res =
|
||||||
|
identity_roles::assign_role(&pool, &user.id, "admin", Some(bad_actor_id), None, None).await;
|
||||||
|
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
// Verify role was not assigned
|
||||||
|
let user_roles = role_repo::list_for_user(&pool, &user.id).await.unwrap();
|
||||||
|
assert!(user_roles.is_empty());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_transaction_rollback_on_audit_failure_create_token() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"rollback_tok_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Try to create token but fail on audit log FK violation
|
||||||
|
let bad_actor_id = "non_existent_actor_id";
|
||||||
|
let res = tokens::create_token(
|
||||||
|
&pool,
|
||||||
|
&user.id,
|
||||||
|
"my-pat-token",
|
||||||
|
&sec_cfg,
|
||||||
|
Some(bad_actor_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
// Verify no tokens were created for the user
|
||||||
|
let user_tokens = token_repo::list_for_user(&pool, &user.id).await.unwrap();
|
||||||
|
assert!(user_tokens.is_empty());
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_assign_non_existent_role_fails() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let user = identity_users::create_user(
|
||||||
|
&pool,
|
||||||
|
&sec_cfg,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
"no_role_user",
|
||||||
|
"super_secure_passphrase_123",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let res =
|
||||||
|
identity_roles::assign_role(&pool, &user.id, "non_existent_role_name", None, None, None)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err());
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_create_token_non_existent_user_fails() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let res = tokens::create_token(
|
||||||
|
&pool,
|
||||||
|
"non_existent_user_id",
|
||||||
|
"my-token",
|
||||||
|
&sec_cfg,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(res.is_err());
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_service_account_audit_lifecycle() {
|
||||||
|
let (pool, db_path) = setup_test_db().await;
|
||||||
|
let name = "my-service-account";
|
||||||
|
let desc = Some("A test description");
|
||||||
|
|
||||||
|
// 1. Create service account
|
||||||
|
let sa = nx9_auth::identity::service_accounts::create(
|
||||||
|
&pool,
|
||||||
|
Tenant::DEFAULT_ID,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sa.name, name);
|
||||||
|
assert_eq!(sa.description.as_deref(), desc);
|
||||||
|
assert!(sa.enabled);
|
||||||
|
|
||||||
|
// 2. Disable service account
|
||||||
|
let res_disable =
|
||||||
|
nx9_auth::identity::service_accounts::set_enabled(&pool, &sa.id, false, None, None, None)
|
||||||
|
.await;
|
||||||
|
assert!(res_disable.is_ok());
|
||||||
|
|
||||||
|
let sa_disabled = nx9_auth::identity::service_accounts::list(&pool, Tenant::DEFAULT_ID)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.id == sa.id)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!sa_disabled.enabled);
|
||||||
|
|
||||||
|
teardown_test_db(db_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_verify_dummy_execution() {
|
||||||
|
let sec_cfg = test_security_config();
|
||||||
|
let res = passwords::verify_dummy(&sec_cfg);
|
||||||
|
assert!(res.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_security_invalid_password_strength_admin() {
|
||||||
|
// Admin password needs to be at least 12 characters
|
||||||
|
let res = passwords::validate_password_strength("too_short_1", true);
|
||||||
|
assert!(res.is_err());
|
||||||
|
|
||||||
|
let res_ok = passwords::validate_password_strength("long_enough_admin_pass_123", true);
|
||||||
|
assert!(res_ok.is_ok());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user