Initial public release v0.1.0

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

No files matched your search

+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(),
}
}
}