Initial public release v0.1.0

This commit is contained in:
thakares committed 2026-06-21 20:05:29 +05:30
commit 4f594d545a
87 files changed
+10867

No files matched your search

+41
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+82
View File
@@ -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
View File
@@ -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"]
+138
View File
@@ -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
```
```
+41
View File
@@ -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");
}
+57
View File
@@ -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"
+40
View File
@@ -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
+171
View File
@@ -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 ""
+26
View File
@@ -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:
+86
View File
@@ -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
```
+50
View File
@@ -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.
+94
View File
@@ -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
View File
@@ -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
```
+113
View File
@@ -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"}` |
+47
View File
@@ -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
+56
View File
@@ -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
View File
@@ -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,
}))
}
+7
View File
@@ -0,0 +1,7 @@
use axum::Json;
use serde_json::{Value, json};
/// GET /health
pub async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
}
+6
View File
@@ -0,0 +1,6 @@
pub mod auth;
pub mod health;
pub mod router;
pub mod tokens;
pub mod users;
pub mod version;
+51
View File
@@ -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)
}
+128
View File
@@ -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 })))
}
+166
View File
@@ -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 })))
}
+15
View File
@@ -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"),
}))
}
+84
View File
@@ -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(())
}
+3
View File
@@ -0,0 +1,3 @@
#[allow(clippy::module_inception)]
pub mod audit;
pub use audit::{AuditEvent, log};
+178
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+272
View File
@@ -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")
);
}
}
+10
View File
@@ -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);
+16
View File
@@ -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
);
+5
View File
@@ -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);
+68
View File
@@ -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;
+20
View File
@@ -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,
}
+13
View File
@@ -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,
}
+55
View File
@@ -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,
}
+20
View File
@@ -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};
+9
View File
@@ -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>,
}
+9
View File
@@ -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>,
}
+13
View File
@@ -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,
}
+23
View File
@@ -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,
}
+17
View File
@@ -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";
}
+68
View File
@@ -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
}
}
+60
View File
@@ -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(())
}
+49
View File
@@ -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
}
+8
View File
@@ -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;
+41
View File
@@ -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)
}
+70
View File
@@ -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)
}
+59
View File
@@ -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(())
}
+79
View File
@@ -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())
}
+74
View File
@@ -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(())
}
+121
View File
@@ -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)
}
+104
View File
@@ -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);
}
}
+31
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
pub mod applications;
pub mod permissions;
pub mod roles;
pub mod service_accounts;
pub mod users;
+37
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
+88
View File
@@ -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(())
}
+187
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+50
View File
@@ -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,
})
}
}
+96
View File
@@ -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)
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod audit;
pub mod auth;
pub mod permissions;
+12
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
pub mod passwords;
pub mod rate_limit;
pub mod sessions;
pub mod tokens;
pub use rate_limit::RateLimiter;
+143
View File
@@ -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());
}
}
+191
View File
@@ -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)
);
}
}
+131
View File
@@ -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);
}
}
+165
View File
@@ -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);
}
}
+23
View File
@@ -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(),
}
}
}
+287
View File
@@ -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
+181
View File
@@ -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;
}
+585
View File
@@ -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());
}