Release: NX9-Auth v0.3.0

This commit is contained in:
thakares committed 2026-07-22 19:36:22 +05:30
1 parent 6a04d7f793
commit d93f2cef95
92 files changed
+2418 -1143

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{
db::models::AuditLog,
db::repository::audit::{self as audit_repo, AuditFilter},
db::models::{AuditFilter, AuditLog},
db::repository::audit as audit_repo,
error::{AppError, Result},
middleware::{auth::AuthUser, permissions::require},
state::AppState,
+4 -1
View File
@@ -112,7 +112,10 @@ pub async fn login(
return Err(AppError::InvalidCredentials);
}
let user = final_user.expect("authenticated user");
let user = match final_user {
Some(u) => u,
None => return Err(AppError::InvalidCredentials),
};
// Clear rate limit on success
if let Some(ip_str) = &ctx.ip_address {
+2 -2
View File
@@ -84,7 +84,7 @@ pub async fn dashboard(State(state): State<AppState>, auth: AuthUser) -> Result<
let recent_personal = state
.provider
.audit()
.list_filtered(&crate::db::repository::audit::AuditFilter {
.list_filtered(&crate::db::models::AuditFilter {
actor_user_id: Some(auth.user.id.clone()),
limit: 10,
..Default::default()
@@ -175,7 +175,7 @@ pub async fn dashboard(State(state): State<AppState>, auth: AuthUser) -> Result<
let recent_logins = state
.provider
.audit()
.list_filtered(&crate::db::repository::audit::AuditFilter {
.list_filtered(&crate::db::models::AuditFilter {
action: Some("login_success".into()),
limit: 10,
..Default::default()
+21 -2
View File
@@ -1,7 +1,26 @@
use axum::Json;
use axum::extract::State;
use serde_json::{Value, json};
use crate::state::AppState;
/// GET /health
pub async fn health() -> Json<Value> {
Json(json!({ "status": "ok" }))
pub async fn health(State(state): State<AppState>) -> Json<Value> {
let backend = state
.config
.database
.resolved_url()
.map(|(_, b)| b.to_string())
.unwrap_or_else(|_| "unknown".to_string());
let db_status = match state.provider.tenants().list().await {
Ok(_) => "connected",
Err(_) => "error",
};
Json(json!({
"status": if db_status == "connected" { "ok" } else { "degraded" },
"db_backend": backend,
"database_status": db_status
}))
}
+22
View File
@@ -62,6 +62,28 @@ pub async fn serve_ui(uri: Uri) -> Response {
return StatusCode::NOT_FOUND.into_response();
}
// Security Hardening: Reject & sanitize any GET request containing credentials in query string.
if let Some(query) = uri.query() {
let q_lower = query.to_ascii_lowercase();
if q_lower.contains("password=")
|| q_lower.contains("username=")
|| q_lower.contains("secret=")
{
tracing::warn!(path = %uri.path(), "rejected credential query parameters in GET request");
let clean_path = if uri.path().is_empty() {
"/"
} else {
uri.path()
};
return Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, clean_path)
.header(header::CACHE_CONTROL, "no-store")
.body(Body::empty())
.unwrap_or_else(|_| StatusCode::BAD_REQUEST.into_response());
}
}
// Normalize and reject path traversal
if path.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
+13 -2
View File
@@ -1,15 +1,26 @@
use axum::Json;
use axum::extract::State;
use serde_json::{Value, json};
use crate::state::AppState;
/// GET /version
///
/// Returns build metadata baked in at compile time via `build.rs`.
pub async fn version() -> Json<Value> {
/// Returns build metadata baked in at compile time via `build.rs` and active db_backend.
pub async fn version(State(state): State<AppState>) -> Json<Value> {
let backend = state
.config
.database
.resolved_url()
.map(|(_, b)| b.to_string())
.unwrap_or_else(|_| "unknown".to_string());
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"),
"db_backend": backend,
}))
}
+11
View File
@@ -1,12 +1,16 @@
#[cfg(feature = "sqlite")]
use nx9_auth::{
config::SecurityConfig,
db::{self, models::Tenant, provider::SqliteProvider},
identity::users as identity_users,
security::{passwords, sessions, tokens},
};
#[cfg(feature = "sqlite")]
use std::sync::Arc;
#[cfg(feature = "sqlite")]
use std::time::Instant;
#[cfg(feature = "sqlite")]
async fn setup_bench_db() -> (Arc<dyn nx9_auth::db::provider::DatabaseProvider>, String) {
let db_id = uuid::Uuid::new_v4().to_string();
let db_path = format!("target/bench_{}.db", db_id);
@@ -21,6 +25,7 @@ async fn setup_bench_db() -> (Arc<dyn nx9_auth::db::provider::DatabaseProvider>,
(provider, db_path)
}
#[cfg(feature = "sqlite")]
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();
@@ -39,6 +44,7 @@ fn print_stats(name: &str, mut durations: Vec<std::time::Duration>, count: usize
println!();
}
#[cfg(feature = "sqlite")]
#[tokio::main]
async fn main() {
println!("Starting nx9-auth microbenchmarks...");
@@ -178,3 +184,8 @@ async fn main() {
let _ = std::fs::remove_file(db_path);
}
#[cfg(not(feature = "sqlite"))]
fn main() {
println!("Benchmark binary requires the 'sqlite' feature");
}
+192 -283
View File
@@ -1,3 +1,4 @@
use anyhow::Context;
use std::io::{self, Write};
use std::path::PathBuf;
@@ -161,6 +162,12 @@ pub enum Commands {
/// Path where the backup file will be created.
path: PathBuf,
},
/// Restore the database from a backup file.
Restore {
/// Path to the backup file to restore from.
path: PathBuf,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -242,118 +249,55 @@ pub async fn run(command: Commands, config: Config) -> anyhow::Result<()> {
} => cmd_show_user(&config, &id_or_username, permissions).await,
Commands::ShowToken { id } => cmd_show_token(&config, &id).await,
Commands::Backup { path } => cmd_backup(&config, &path).await,
Commands::Restore { path } => cmd_restore(&config, &path).await,
}
}
// ── migrate ───────────────────────────────────────────────────────────────────
async fn cmd_migrate(config: &Config) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
db::run_migrations(&pool).await?;
println!("✓ Migrations applied successfully.");
let (_provider, backend, _pool) = db::init_provider(config).await?;
println!("✓ Migrations applied successfully ({backend}).");
Ok(())
}
// ── doctor ────────────────────────────────────────────────────────────────────
fn make_provider(
pool: sqlx::SqlitePool,
) -> std::sync::Arc<dyn crate::db::provider::DatabaseProvider> {
#[cfg(feature = "sqlite")]
{
std::sync::Arc::new(crate::db::provider::SqliteProvider::new(pool))
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
{
std::sync::Arc::new(crate::db::provider::PostgresProvider::new(pool))
}
}
async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
let mut ok = true;
println!("\nnx9-auth doctor\n");
// 1. Config loads (already done — we got here with a valid config)
// 1. Config file loads
println!(" ✓ Config file loads and parses");
// 2. DB path is writable
let db_path = std::path::Path::new(&config.database.path);
let db_dir_writable = if let Some(parent) = db_path.parent() {
if parent.as_os_str().is_empty() {
true
} else if std::fs::create_dir_all(parent).is_err() {
false
} else {
let temp_file = parent.join(format!(
".nx9_auth_doctor_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
if std::fs::write(&temp_file, b"test").is_ok() {
let _ = std::fs::remove_file(temp_file);
true
} else {
false
}
// 2. DB backend & connection
let (url, backend) = match config.database.resolved_url() {
Ok(res) => res,
Err(e) => {
println!(" ✗ Failed to resolve database configuration: {e}");
println!("\nDoctor result: FAIL\n");
return Ok(false);
}
} else {
true
};
if db_dir_writable {
println!(" ✓ Database directory is writable");
} else {
println!(
" ✗ Database directory is not writable: {}",
config.database.path
);
ok = false;
}
println!(" ✓ Database backend detected: {backend}");
println!(" ✓ Database URL: {url}");
// 3. DB connects
let pool_result = db::create_pool(&config.database.path).await;
let pool = match pool_result {
Ok(p) => {
println!(" ✓ Database connection successful");
let provider = match db::init_provider(config).await {
Ok((p, _, _)) => {
println!(" ✓ Database connection & migrations successful");
p
}
Err(e) => {
println!(" ✗ Database connection failed: {}", e);
println!(" ✗ Database initialization failed: {e}");
println!("\nDoctor result: FAIL\n");
return Ok(false);
}
};
let provider = make_provider(pool.clone());
// 4. Migrations are up to date
// Verify migrations are applied
let migration_check: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(&pool)
.await;
match migration_check {
Ok((count,)) if count > 0 => println!(" ✓ Migrations applied ({} recorded)", count),
Ok(_) => {
println!(" ✗ No migrations recorded — run `nx9-auth migrate` first");
ok = false;
}
Err(_) => {
println!(" ✗ Migrations table missing — run `nx9-auth migrate` first");
ok = false;
}
}
// 5. Default tenant exists
let tenant_check: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM tenants WHERE id = ?")
.bind(Tenant::DEFAULT_ID)
.fetch_one(&pool)
.await;
match tenant_check {
Ok((1,)) => println!(" ✓ Default tenant exists"),
match provider.tenants().find_by_id(Tenant::DEFAULT_ID).await {
Ok(Some(_)) => println!(" ✓ Default tenant exists"),
_ => {
println!(" ✗ Default tenant missing — run `nx9-auth migrate`");
ok = false;
@@ -386,102 +330,6 @@ async fn run_doctor_checks(config: &Config) -> anyhow::Result<bool> {
}
}
// 8. WAL mode
let journal_mode: Result<(String,), sqlx::Error> =
sqlx::query_as("PRAGMA journal_mode").fetch_one(&pool).await;
match journal_mode {
Ok((mode,)) if mode.to_lowercase() == "wal" => println!(" ✓ WAL mode enabled"),
Ok((mode,)) => {
println!(" ✗ WAL mode not enabled (current mode: {})", mode);
ok = false;
}
Err(e) => {
println!(" ✗ Failed to check journal mode: {}", e);
ok = false;
}
}
// 9. Foreign Keys
let foreign_keys: Result<(i64,), sqlx::Error> =
sqlx::query_as("PRAGMA foreign_keys").fetch_one(&pool).await;
match foreign_keys {
Ok((1,)) => println!(" ✓ Foreign keys constraint enforcement enabled"),
Ok((val,)) => {
println!(
" ✗ Foreign keys constraint enforcement disabled (current value: {})",
val
);
ok = false;
}
Err(e) => {
println!(" ✗ Failed to check foreign keys: {}", e);
ok = false;
}
}
// 10. Table existence
for table in &["audit_logs", "sessions"] {
let table_exists: Result<Option<(String,)>, sqlx::Error> =
sqlx::query_as("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
.bind(table)
.fetch_optional(&pool)
.await;
match table_exists {
Ok(Some(_)) => println!(" ✓ Table '{}' exists", table),
Ok(None) => {
println!(" ✗ Table '{}' is missing", table);
ok = false;
}
Err(e) => {
println!(" ✗ Failed to check existence of table '{}': {}", table, e);
ok = false;
}
}
}
// 11. Database Write Test
let write_test: Result<(), sqlx::Error> = async {
let mut tx = pool.begin().await?;
sqlx::query("CREATE TEMP TABLE doctor_test_write (id INTEGER PRIMARY KEY)")
.execute(&mut *tx)
.await?;
sqlx::query("INSERT INTO doctor_test_write (id) VALUES (1)")
.execute(&mut *tx)
.await?;
sqlx::query("DROP TABLE doctor_test_write")
.execute(&mut *tx)
.await?;
Ok(())
}
.await;
match write_test {
Ok(()) => {
println!(" ✓ Database write test successful (temp table creation and deletion)")
}
Err(e) => {
println!(" ✗ Database write test failed: {}", e);
ok = false;
}
}
// 12. Database Integrity Check
let integrity_check: Result<(String,), sqlx::Error> = sqlx::query_as("PRAGMA integrity_check")
.fetch_one(&pool)
.await;
match integrity_check {
Ok((res,)) if res.to_lowercase() == "ok" => {
println!(" ✓ Database integrity check passed")
}
Ok((res,)) => {
println!(" ✗ Database integrity check failed: {}", res);
ok = false;
}
Err(e) => {
println!(" ✗ Failed to run database integrity check: {}", e);
ok = false;
}
}
println!();
if ok {
println!("Doctor result: OK\n");
@@ -503,8 +351,7 @@ async fn cmd_doctor(config: &Config) -> anyhow::Result<()> {
// ── create-admin ──────────────────────────────────────────────────────────────
async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let password = prompt_password_confirmed("Password for admin: ", true)?;
@@ -529,8 +376,7 @@ async fn cmd_create_admin(config: &Config, username: &str) -> anyhow::Result<()>
// ── create-user ───────────────────────────────────────────────────────────────
async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let password = prompt_password_confirmed("Password: ", false)?;
@@ -553,8 +399,7 @@ async fn cmd_create_user(config: &Config, username: &str) -> anyhow::Result<()>
// ── list-users ────────────────────────────────────────────────────────────────
async fn cmd_list_users(config: &Config) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let users = provider.users().list(Tenant::DEFAULT_ID).await?;
@@ -590,8 +435,7 @@ async fn cmd_set_status(
id_or_username: &str,
status: UserStatus,
) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
identity_users::update_status(&provider, &user.id, status.as_i32(), None, None, None).await?;
@@ -606,8 +450,7 @@ async fn cmd_set_status(
// ── reset-password ────────────────────────────────────────────────────────────
async fn cmd_reset_password(config: &Config, id_or_username: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
let user_roles = provider.roles().list_for_user(&user.id).await?;
@@ -631,8 +474,7 @@ async fn cmd_reset_password(config: &Config, id_or_username: &str) -> anyhow::Re
// ── create-token ──────────────────────────────────────────────────────────────
async fn cmd_create_token(config: &Config, user_ref: &str, name: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, user_ref).await?;
let (token, raw) = token_security::create_token(
@@ -667,8 +509,7 @@ async fn cmd_create_token(config: &Config, user_ref: &str, name: &str) -> anyhow
// ── revoke-token ──────────────────────────────────────────────────────────────
async fn cmd_revoke_token(config: &Config, id: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let token = provider
.tokens()
@@ -722,7 +563,8 @@ async fn cmd_init(
}
}
let db_path = std::path::Path::new(&config.database.path);
let sqlite_path = config.database.sqlite_path();
let db_path = std::path::Path::new(&sqlite_path);
println!("Creating database directory...");
if let Some(parent) = db_path.parent() {
if !parent.as_os_str().is_empty() {
@@ -738,12 +580,9 @@ async fn cmd_init(
}
// 2. Open DB pool and run migrations
println!("Running migrations...");
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool.clone());
db::run_migrations(&pool).await?;
println!("✓ Migrations applied successfully.");
println!("Initializing database and migrations...");
let (provider, backend, _pool) = db::init_provider(config).await?;
println!("✓ Database initialized ({backend}).");
// 3. Create administrator
if skip_admin {
@@ -822,7 +661,8 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
println!(" ✓ Configuration");
// 2. Directories writable
let db_path = std::path::Path::new(&config.database.path);
let sqlite_path = config.database.sqlite_path();
let db_path = std::path::Path::new(&sqlite_path);
let mut dirs_ok = true;
if let Some(parent) = db_path.parent() {
if !parent.as_os_str().is_empty() && std::fs::create_dir_all(parent).is_err() {
@@ -842,10 +682,11 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
ok = false;
}
// 3. Database reachable
let pool = match db::create_pool(&config.database.path).await {
Ok(p) => {
// 3. Database & Migrations reachable
let provider = match db::init_provider(config).await {
Ok((p, _, _)) => {
println!(" ✓ Database");
println!(" ✓ Migrations");
p
}
Err(e) => {
@@ -854,21 +695,7 @@ async fn run_init_validation(config: &Config, admin_skipped: bool) -> anyhow::Re
}
};
// 4. Migrations applied
let migration_check: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM _sqlx_migrations")
.fetch_one(&pool)
.await;
match migration_check {
Ok((count,)) if count > 0 => println!(" ✓ Migrations"),
_ => {
println!(" ✗ Migrations not applied");
ok = false;
}
}
// 5. Admin account check
let provider = make_provider(pool);
// 4. Admin account check
let admin_count = provider.users().count_admins().await.unwrap_or(0);
if admin_count > 0 {
println!(" ✓ Administrator account");
@@ -891,7 +718,12 @@ async fn cmd_config_path(config: &Config, json: bool) -> anyhow::Result<()> {
.or_else(Config::default_user_config_path)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let database_file = config.database.path.clone();
let (database_url, _) = config.database.resolved_url().unwrap_or_else(|_| {
(
config.database.sqlite_path(),
crate::config::DatabaseBackend::Sqlite,
)
});
let state_dir = if let Ok(home) = std::env::var("HOME") {
std::path::Path::new(&home)
@@ -905,7 +737,7 @@ async fn cmd_config_path(config: &Config, json: bool) -> anyhow::Result<()> {
if json {
let val = serde_json::json!({
"config": config_file,
"database": database_file,
"database": database_url,
"state": state_dir,
});
println!("{}", serde_json::to_string_pretty(&val)?);
@@ -913,7 +745,7 @@ async fn cmd_config_path(config: &Config, json: bool) -> anyhow::Result<()> {
println!("\nConfig:");
println!(" {}", config_file);
println!("\nDatabase:");
println!(" {}", database_file);
println!(" {}", database_url);
println!("\nLogs/State:");
println!(" {}", state_dir);
println!();
@@ -928,8 +760,7 @@ async fn cmd_show_user(
id_or_username: &str,
permissions: bool,
) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let user = resolve_user(&provider, id_or_username).await?;
@@ -978,8 +809,7 @@ async fn cmd_show_user(
// ── show-token ────────────────────────────────────────────────────────────────
async fn cmd_show_token(config: &Config, id: &str) -> anyhow::Result<()> {
let pool = db::create_pool(&config.database.path).await?;
let provider = make_provider(pool);
let (provider, _backend, _pool) = db::init_provider(config).await?;
let token = provider
.tokens()
@@ -1020,70 +850,149 @@ async fn cmd_show_token(config: &Config, id: &str) -> anyhow::Result<()> {
// ── backup ────────────────────────────────────────────────────────────────────
async fn cmd_backup(config: &Config, path: &std::path::Path) -> anyhow::Result<()> {
// 1. Resolve paths to absolute paths
let source_path = std::path::Path::new(&config.database.path);
let (url, backend) = config.database.resolved_url()?;
match backend {
crate::config::DatabaseBackend::Sqlite => {
let sqlite_path = config.database.sqlite_path();
let source_path = std::path::Path::new(&sqlite_path);
let abs_source =
std::fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf());
let abs_target = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let abs_source =
std::fs::canonicalize(source_path).unwrap_or_else(|_| source_path.to_path_buf());
let source_dir = abs_source
.parent()
.ok_or_else(|| anyhow::anyhow!("invalid database source path"))?;
let source_file_name = abs_source
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid database file name"))?
.to_string_lossy();
let source_wal = source_dir.join(format!("{}-wal", source_file_name));
let source_shm = source_dir.join(format!("{}-shm", source_file_name));
let abs_target = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
if abs_target == abs_source {
anyhow::bail!(
"Backup destination cannot be the active database file: {}",
path.display()
);
}
if abs_target == source_wal {
anyhow::bail!(
"Backup destination cannot be the active WAL file: {}",
path.display()
);
}
if abs_target == source_shm {
anyhow::bail!(
"Backup destination cannot be the active SHM file: {}",
path.display()
);
}
let source_dir = abs_source.parent().unwrap();
let source_file_name = abs_source.file_name().unwrap().to_string_lossy();
let source_wal = source_dir.join(format!("{}-wal", source_file_name));
let source_shm = source_dir.join(format!("{}-shm", source_file_name));
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if abs_target == abs_source {
anyhow::bail!(
"Backup destination cannot be the active database file: {}",
path.display()
);
}
if abs_target == source_wal {
anyhow::bail!(
"Backup destination cannot be the active WAL file: {}",
path.display()
);
}
if abs_target == source_shm {
anyhow::bail!(
"Backup destination cannot be the active SHM file: {}",
path.display()
);
}
if path.exists() {
std::fs::remove_file(path)?;
}
// 2. Ensure parent directory exists
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
#[cfg(feature = "sqlite")]
{
let pool = db::create_pool(&sqlite_path).await?;
let path_str = path.to_string_lossy().replace('\'', "''");
let query = format!("VACUUM INTO '{}'", path_str);
sqlx::query(sqlx::AssertSqlSafe(query))
.execute(&pool)
.await?;
println!(
"✓ SQLite database backup created successfully at: {}",
path.display()
);
}
#[cfg(not(feature = "sqlite"))]
{
anyhow::bail!("SQLite database backups require the 'sqlite' feature");
}
}
crate::config::DatabaseBackend::Postgres => {
let output = std::process::Command::new("pg_dump")
.arg("-Fc")
.arg("-d")
.arg(&url)
.arg("-f")
.arg(path)
.output()
.context(
"failed to execute pg_dump (ensure PostgreSQL client tools are installed)",
)?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pg_dump failed: {err}");
}
println!(
"✓ PostgreSQL database backup created successfully at: {}",
path.display()
);
}
}
Ok(())
}
async fn cmd_restore(config: &Config, path: &std::path::Path) -> anyhow::Result<()> {
if !path.exists() {
anyhow::bail!("Backup file does not exist: {}", path.display());
}
let (url, backend) = config.database.resolved_url()?;
match backend {
crate::config::DatabaseBackend::Sqlite => {
let sqlite_path = config.database.sqlite_path();
let target_path = std::path::Path::new(&sqlite_path);
if let Some(parent) = target_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::copy(path, target_path).with_context(|| {
format!("failed to restore backup to {}", target_path.display())
})?;
println!(
"✓ SQLite database restored successfully from: {}",
path.display()
);
}
crate::config::DatabaseBackend::Postgres => {
let output = std::process::Command::new("pg_restore")
.arg("--clean")
.arg("--if-exists")
.arg("-d")
.arg(&url)
.arg(path)
.output()
.context(
"failed to execute pg_restore (ensure PostgreSQL client tools are installed)",
)?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("pg_restore failed: {err}");
}
println!(
"✓ PostgreSQL database restored successfully from: {}",
path.display()
);
}
}
// 3. Delete target file if it already exists to overwrite
if path.exists() {
std::fs::remove_file(path)?;
}
// 4. Perform SQLite VACUUM INTO
// VACUUM INTO is a standard SQL statement supported by SQLite
// for transactionally consistent online backups. It is the modern
// SQL alternative to the online backup C API, especially on WAL-enabled databases.
let pool = db::create_pool(&config.database.path).await?;
let path_str = path.to_string_lossy().replace('\'', "''");
let query = format!("VACUUM INTO '{}'", path_str);
sqlx::query(sqlx::AssertSqlSafe(query))
.execute(&pool)
.await?;
println!(
"✓ Database backup created successfully at: {}",
path.display()
);
Ok(())
}
+172 -6
View File
@@ -19,6 +19,9 @@ pub struct Config {
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub shutdown: ShutdownConfig,
}
#[derive(Debug, Deserialize, Clone)]
@@ -40,10 +43,48 @@ pub struct ServerConfig {
pub production: bool,
}
use std::fmt::Display;
/// Supported database backends.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseBackend {
Sqlite,
Postgres,
}
impl Display for DatabaseBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sqlite => write!(f, "sqlite"),
Self::Postgres => write!(f, "postgres"),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
/// Path to the SQLite database file (supports ~ prefix).
pub path: String,
/// Unified database connection URL (e.g., sqlite://./auth.db or postgres://user:pass@host/db).
#[serde(default)]
pub url: Option<String>,
/// Legacy path to SQLite database file.
#[serde(default)]
pub path: Option<String>,
/// Maximum connection pool size.
#[serde(default)]
pub max_connections: Option<u32>,
/// Minimum connection pool size.
#[serde(default)]
pub min_connections: Option<u32>,
/// Connection timeout in seconds.
#[serde(default)]
pub connect_timeout_secs: Option<u64>,
/// Idle connection timeout in seconds.
#[serde(default)]
pub idle_timeout_secs: Option<u64>,
/// Maximum connection lifetime in seconds.
#[serde(default)]
pub max_lifetime_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Clone)]
@@ -112,7 +153,73 @@ impl Default for DatabaseConfig {
"/var/lib/nx9-auth/auth.db".to_string()
};
Self {
path: default_db_path,
url: None,
path: Some(default_db_path),
max_connections: None,
min_connections: None,
connect_timeout_secs: None,
idle_timeout_secs: None,
max_lifetime_secs: None,
}
}
}
impl DatabaseConfig {
/// Resolve and normalize the database URL and derive the active backend.
pub fn resolved_url(&self) -> Result<(String, DatabaseBackend)> {
let raw = if let Some(ref url) = self.url {
let trimmed = url.trim();
if !trimmed.is_empty() {
trimmed.to_string()
} else if let Some(ref path) = self.path {
path.trim().to_string()
} else {
anyhow::bail!("missing database url or path configuration");
}
} else if let Some(ref path) = self.path {
path.trim().to_string()
} else {
anyhow::bail!("missing database url or path configuration");
};
if raw.starts_with("postgres://") || raw.starts_with("postgresql://") {
Ok((raw, DatabaseBackend::Postgres))
} else if raw.starts_with("sqlite://") {
Ok((raw, DatabaseBackend::Sqlite))
} else if self.url.is_some() && raw.contains("://") {
anyhow::bail!("unknown or malformed database URL scheme in '{raw}'");
} else {
// Treat plain file path as SQLite
let path = resolve_home_path(&raw);
let url = format!("sqlite://{path}?mode=rwc");
Ok((url, DatabaseBackend::Sqlite))
}
}
/// Retrieve the SQLite path for legacy file-based commands.
pub fn sqlite_path(&self) -> String {
if let Some(ref path) = self.path {
resolve_home_path(path)
} else if let Some(ref url) = self.url {
if let Some(stripped) = url.strip_prefix("sqlite://") {
let clean = stripped.split('?').next().unwrap_or(stripped);
resolve_home_path(clean)
} else {
url.clone()
}
} else {
self.default_path()
}
}
fn default_path(&self) -> String {
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()
}
}
}
@@ -136,6 +243,53 @@ impl Default for AuditConfig {
}
}
/// Shutdown timeout configuration.
#[derive(Debug, Deserialize, Clone)]
pub struct ShutdownConfig {
/// Maximum time (seconds) to wait for graceful shutdown of HTTP
/// connections and background workers.
#[serde(default = "ShutdownConfig::default_graceful_timeout")]
pub graceful_timeout_secs: u64,
/// Hard timeout (seconds) after which shutdown is forced. Must be
/// greater than `graceful_timeout_secs`.
#[serde(default = "ShutdownConfig::default_force_timeout")]
pub force_timeout_secs: u64,
}
impl ShutdownConfig {
fn default_graceful_timeout() -> u64 {
30
}
fn default_force_timeout() -> u64 {
35
}
/// Validate timeout invariants at startup.
pub fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.graceful_timeout_secs > 0,
"shutdown.graceful_timeout_secs must be > 0 (got {})",
self.graceful_timeout_secs
);
anyhow::ensure!(
self.force_timeout_secs > self.graceful_timeout_secs,
"shutdown.force_timeout_secs ({}) must be > graceful_timeout_secs ({})",
self.force_timeout_secs,
self.graceful_timeout_secs
);
Ok(())
}
}
impl Default for ShutdownConfig {
fn default() -> Self {
Self {
graceful_timeout_secs: 30,
force_timeout_secs: 35,
}
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn resolve_home_path(path: &str) -> String {
@@ -155,7 +309,15 @@ fn resolve_home_path(path: &str) -> String {
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);
if let Some(ref mut path) = self.database.path {
*path = resolve_home_path(path);
}
if let Some(ref mut url) = self.database.url {
if let Some(stripped) = url.strip_prefix("sqlite://") {
let clean = resolve_home_path(stripped);
*url = format!("sqlite://{clean}");
}
}
}
/// Load and parse config from a TOML file.
@@ -288,9 +450,13 @@ mod tests {
assert!(!cfg.server.cookie_secure);
assert!(!cfg.server.production);
if std::env::var("HOME").is_ok() {
assert!(cfg.database.path.contains(".local/share/nx9-auth/auth.db"));
assert!(
cfg.database
.sqlite_path()
.contains(".local/share/nx9-auth/auth.db")
);
} else {
assert_eq!(cfg.database.path, "/var/lib/nx9-auth/auth.db");
assert_eq!(cfg.database.sqlite_path(), "/var/lib/nx9-auth/auth.db");
}
assert_eq!(cfg.security.session_ttl_hours, 24);
assert_eq!(cfg.security.session_absolute_ttl_days, 30);
+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);
@@ -0,0 +1,12 @@
-- Opaque refresh tokens issued at login (hashed at rest with BLAKE3).
CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
@@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS tenants (
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'))
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')),
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
@@ -6,8 +6,8 @@ CREATE TABLE IF NOT EXISTS users (
-- 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')),
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
UNIQUE (tenant_id, username)
);
@@ -4,9 +4,9 @@ CREATE TABLE IF NOT EXISTS sessions (
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')),
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_seen_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
revoked INTEGER NOT NULL DEFAULT 0
);
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS api_tokens (
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')),
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
revoked INTEGER NOT NULL DEFAULT 0
);
@@ -4,8 +4,8 @@ CREATE TABLE IF NOT EXISTS service_accounts (
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')),
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
UNIQUE (tenant_id, name)
);
@@ -4,8 +4,8 @@ CREATE TABLE IF NOT EXISTS applications (
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'))
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\')),
updated_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
);
CREATE INDEX IF NOT EXISTS idx_applications_tenant ON applications(tenant_id);
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS audit_logs (
ip_address TEXT,
user_agent TEXT,
metadata_json TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id);
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS global_slugs (
entity_type TEXT NOT NULL, -- 'tenant', 'user', 'role', 'group', 'permission', 'application', 'service_account', 'organization', 'team'
entity_id TEXT NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
created_at TEXT NOT NULL DEFAULT (to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z\'))
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_entity ON global_slugs(entity_type, entity_id);
+180 -44
View File
@@ -1,7 +1,180 @@
use anyhow::{Context, Result};
use std::sync::Arc;
use std::time::Duration;
pub mod models;
pub mod provider;
pub mod repository;
use crate::config::{Config, DatabaseBackend};
use crate::db::provider::DatabaseProvider;
#[cfg(feature = "sqlite")]
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
#[cfg(feature = "postgres")]
use sqlx::postgres::PgPoolOptions;
/// Database connection pool handle owned by the runtime for lifecycle
/// management. Keeps `DatabaseProvider` and repository traits free of
/// lifecycle methods.
pub enum PoolHandle {
#[cfg(feature = "sqlite")]
Sqlite(SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
impl PoolHandle {
/// Close the connection pool, waiting for all borrowed connections
/// to be returned. Active transactions will finish before the pool
/// is fully closed.
pub async fn close(&self) {
match self {
#[cfg(feature = "sqlite")]
Self::Sqlite(pool) => {
pool.close().await;
tracing::info!("sqlite connection pool closed");
}
#[cfg(feature = "postgres")]
Self::Postgres(pool) => {
pool.close().await;
tracing::info!("postgres connection pool closed");
}
}
}
}
/// Initialize database connection pool, run migrations, and return the
/// `DatabaseProvider`, detected backend, and a `PoolHandle` for the runtime
/// to manage the pool lifecycle independently of the repositories.
pub async fn init_provider(
config: &Config,
) -> Result<(Arc<dyn DatabaseProvider>, DatabaseBackend, PoolHandle)> {
let (url, backend) = config.database.resolved_url()?;
match backend {
#[cfg(feature = "sqlite")]
DatabaseBackend::Sqlite => {
let path = config.database.sqlite_path();
if let Some(parent) = std::path::Path::new(&path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create database directory: {}", parent.display())
})?;
}
}
let max_conn = config.database.max_connections.unwrap_or(16);
let min_conn = config.database.min_connections.unwrap_or(1);
let mut opts = SqlitePoolOptions::new()
.max_connections(max_conn)
.min_connections(min_conn);
if let Some(secs) = config.database.connect_timeout_secs {
opts = opts.acquire_timeout(Duration::from_secs(secs));
}
if let Some(secs) = config.database.idle_timeout_secs {
opts = opts.idle_timeout(Duration::from_secs(secs));
}
if let Some(secs) = config.database.max_lifetime_secs {
opts = opts.max_lifetime(Duration::from_secs(secs));
}
let pool = opts
.connect(&url)
.await
.with_context(|| format!("failed to open sqlite database: {url}"))?;
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::migrate!("src/db/migrations/sqlite")
.run(&pool)
.await
.context("failed to run sqlite migrations")?;
tracing::info!(backend = "sqlite", url = %url, "sqlite database initialized");
let pool_handle = PoolHandle::Sqlite(pool.clone());
let provider = Arc::new(provider::SqliteProvider::new(pool));
Ok((provider, DatabaseBackend::Sqlite, pool_handle))
}
#[cfg(feature = "postgres")]
DatabaseBackend::Postgres => {
let max_conn = config.database.max_connections.unwrap_or(16);
let min_conn = config.database.min_connections.unwrap_or(1);
let mut opts = PgPoolOptions::new()
.max_connections(max_conn)
.min_connections(min_conn);
if let Some(secs) = config.database.connect_timeout_secs {
opts = opts.acquire_timeout(Duration::from_secs(secs));
}
if let Some(secs) = config.database.idle_timeout_secs {
opts = opts.idle_timeout(Duration::from_secs(secs));
}
if let Some(secs) = config.database.max_lifetime_secs {
opts = opts.max_lifetime(Duration::from_secs(secs));
}
// Retry connection policy (5 attempts with exponential backoff)
let mut attempts = 0;
let mut wait_secs = 1u64;
let pool = loop {
match opts.clone().connect(&url).await {
Ok(p) => break p,
Err(err) => {
attempts += 1;
if attempts >= 5 {
anyhow::bail!(
"failed to connect to postgres database after {attempts} attempts: {err}"
);
}
tracing::warn!(
attempts,
wait_secs,
"postgres connection failed, retrying..."
);
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
wait_secs = std::cmp::min(wait_secs * 2, 30);
}
}
};
sqlx::migrate!("src/db/migrations/postgres")
.run(&pool)
.await
.context("failed to run postgres migrations")?;
tracing::info!(backend = "postgres", url = %url, "postgres database initialized");
let pool_handle = PoolHandle::Postgres(pool.clone());
let provider = Arc::new(provider::PostgresProvider::new(pool));
Ok((provider, DatabaseBackend::Postgres, pool_handle))
}
#[allow(unreachable_patterns)]
_ => anyhow::bail!("database backend '{backend}' feature is not enabled in this build"),
}
}
/// Helper function to create an SQLite pool for legacy CLI commands or tests.
#[cfg(feature = "sqlite")]
pub async fn create_pool(path: &str) -> Result<SqlitePool> {
if let Some(parent) = std::path::Path::new(path).parent() {
@@ -11,14 +184,18 @@ pub async fn create_pool(path: &str) -> Result<SqlitePool> {
})?;
}
}
let url = if path.starts_with("sqlite://") {
path.to_string()
} else {
format!("sqlite://{}?mode=rwc", path)
};
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}"))?;
.with_context(|| format!("failed to open sqlite database: {path}"))?;
sqlx::query("PRAGMA journal_mode = WAL")
.execute(&pool)
@@ -28,20 +205,7 @@ pub async fn create_pool(path: &str) -> Result<SqlitePool> {
.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")
.execute(&pool)
.await
.context("PRAGMA cache_size")?;
tracing::info!(path = path, "database pool opened");
Ok(pool)
}
@@ -50,34 +214,6 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> {
sqlx::migrate!("src/db/migrations/sqlite")
.run(pool)
.await
.context("failed to run database migrations")?;
tracing::info!("database migrations applied");
.context("failed to run sqlite migrations")?;
Ok(())
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
pub async fn create_pool(url: &str) -> Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(16)
.min_connections(1)
.connect(url)
.await
.with_context(|| format!("failed to open database: {url}"))?;
tracing::info!(url = url, "postgres pool opened");
Ok(pool)
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
pub async fn run_migrations(pool: &PgPool) -> Result<()> {
sqlx::migrate!("src/db/migrations/postgres")
.run(pool)
.await
.context("failed to run postgres migrations")?;
tracing::info!("postgres migrations applied");
Ok(())
}
pub mod models;
pub mod provider;
pub mod repository;
+14
View File
@@ -38,6 +38,20 @@ impl std::fmt::Display for AuditSeverity {
}
}
/// Filtered audit log query. All filters are optional.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AuditFilter {
pub actor_user_id: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub search: Option<String>,
pub limit: i64,
pub offset: i64,
}
/// A row from the `audit_logs` table.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct AuditLog {
+6 -4
View File
@@ -1,7 +1,9 @@
pub mod api_token;
pub mod application;
pub mod audit_log;
pub mod group;
pub mod permission;
pub mod refresh_token;
pub mod role;
pub mod service_account;
pub mod session;
@@ -10,13 +12,13 @@ pub mod user;
pub use api_token::ApiToken;
pub use application::Application;
pub use audit_log::{AuditLog, AuditSeverity};
pub use audit_log::{AuditFilter, AuditLog, AuditSeverity};
pub use group::Group;
#[allow(unused_imports)]
pub use permission::Permission;
pub use refresh_token::RefreshToken;
pub use role::Role;
pub use service_account::ServiceAccount;
pub use session::Session;
pub use tenant::Tenant;
pub use user::{User, UserStatus};
pub mod group;
pub use group::Group;
pub use user::{User, UserProfile, UserStatus};
+10
View File
@@ -57,6 +57,16 @@ pub struct User {
pub updated_at: String,
}
/// User profile fields from `user_profiles`.
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct UserProfile {
pub user_id: String,
pub email: Option<String>,
pub full_name: Option<String>,
pub avatar_url: Option<String>,
pub metadata_json: Option<String>,
}
impl User {
/// Typed status accessor.
pub fn status(&self) -> UserStatus {
+1
View File
@@ -1,5 +1,6 @@
#[cfg(feature = "postgres")]
use sqlx::PgPool;
#[cfg(feature = "sqlite")]
use sqlx::SqlitePool;
use crate::db::repository::traits::*;
+1 -3
View File
@@ -1,6 +1,4 @@
pub use crate::db::repository::sqlite::audit::*;
use crate::db::models::AuditLog;
use crate::db::models::{AuditFilter, AuditLog};
use crate::db::provider::DatabaseProvider;
use std::sync::Arc;
// Removed direct import of AuditFilter to avoid conflict with traits version
+3 -3
View File
@@ -57,8 +57,8 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE applications SET enabled = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
"UPDATE applications SET enabled = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
@@ -77,7 +77,7 @@ impl ApplicationsRepository for PostgresApplicationsRepository {
r#"
UPDATE applications
SET name = $1, slug = $2, enabled = $3,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
WHERE id = $4
"#,
)
+26 -28
View File
@@ -2,14 +2,12 @@ use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::AuditLog;
use crate::db::models::{AuditFilter, AuditLog};
pub struct PostgresAuditRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::audit::AuditFilter;
#[async_trait]
impl AuditRepository for PostgresAuditRepository {
async fn count(&self) -> Result<i64, sqlx::Error> {
@@ -76,22 +74,22 @@ impl AuditRepository for PostgresAuditRepository {
sqlx::query_as::<_, AuditLog>(
r#"
SELECT * FROM audit_logs
WHERE ($11 IS NULL OR actor_user_id = $21)
AND ($32 IS NULL OR action = $42)
AND ($53 IS NULL OR resource_type = $63)
AND ($74 IS NULL OR severity = $84)
AND ($95 IS NULL OR created_at >= $105)
AND ($116 IS NULL OR created_at <= $126)
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR severity = $4)
AND ($5::text IS NULL OR created_at >= $5)
AND ($6::text IS NULL OR created_at <= $6)
AND (
$137 IS NULL
OR action LIKE $147 ESCAPE '\'
OR resource_type LIKE $157 ESCAPE '\'
OR resource_id LIKE $167 ESCAPE '\'
OR ip_address LIKE $177 ESCAPE '\'
OR metadata_json LIKE $187 ESCAPE '\'
$7::text IS NULL
OR action LIKE $7 ESCAPE '\'
OR resource_type LIKE $7 ESCAPE '\'
OR resource_id LIKE $7 ESCAPE '\'
OR ip_address LIKE $7 ESCAPE '\'
OR metadata_json LIKE $7 ESCAPE '\'
)
ORDER BY created_at DESC
LIMIT $198 OFFSET $209
LIMIT $8 OFFSET $9
"#,
)
.bind(filter.actor_user_id.as_deref())
@@ -116,19 +114,19 @@ impl AuditRepository for PostgresAuditRepository {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM audit_logs
WHERE ($11 IS NULL OR actor_user_id = $21)
AND ($32 IS NULL OR action = $42)
AND ($53 IS NULL OR resource_type = $63)
AND ($74 IS NULL OR severity = $84)
AND ($95 IS NULL OR created_at >= $105)
AND ($116 IS NULL OR created_at <= $126)
WHERE ($1::text IS NULL OR actor_user_id = $1)
AND ($2::text IS NULL OR action = $2)
AND ($3::text IS NULL OR resource_type = $3)
AND ($4::text IS NULL OR severity = $4)
AND ($5::text IS NULL OR created_at >= $5)
AND ($6::text IS NULL OR created_at <= $6)
AND (
$137 IS NULL
OR action LIKE $147 ESCAPE '\'
OR resource_type LIKE $157 ESCAPE '\'
OR resource_id LIKE $167 ESCAPE '\'
OR ip_address LIKE $177 ESCAPE '\'
OR metadata_json LIKE $187 ESCAPE '\'
$7::text IS NULL
OR action LIKE $7 ESCAPE '\'
OR resource_type LIKE $7 ESCAPE '\'
OR resource_id LIKE $7 ESCAPE '\'
OR ip_address LIKE $7 ESCAPE '\'
OR metadata_json LIKE $7 ESCAPE '\'
)
"#,
)
+102 -25
View File
@@ -9,54 +9,131 @@ pub struct PostgresGroupsRepository {
#[async_trait]
impl GroupsRepository for PostgresGroupsRepository {
async fn list(&self, _tenant_id: &str) -> Result<Vec<Group>, sqlx::Error> {
unimplemented!()
async fn list(&self, tenant_id: &str) -> Result<Vec<Group>, sqlx::Error> {
let rows = sqlx::query_as::<_, Group>(
r#"
SELECT * FROM groups
WHERE tenant_id = $1
ORDER BY name ASC
"#,
)
.bind(tenant_id)
.fetch_all(&self.pool)
.await?;
Ok(rows)
}
async fn find_by_id(&self, _id: &str) -> Result<Option<Group>, sqlx::Error> {
unimplemented!()
async fn find_by_id(&self, id: &str) -> Result<Option<Group>, sqlx::Error> {
sqlx::query_as::<_, Group>("SELECT * FROM groups WHERE id = $1")
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn create(
&self,
_id: &str,
_tenant_id: &str,
_name: &str,
_description: Option<&str>,
id: &str,
tenant_id: &str,
name: &str,
description: Option<&str>,
) -> Result<Group, sqlx::Error> {
unimplemented!()
sqlx::query_as::<_, Group>(
r#"
INSERT INTO groups (id, tenant_id, name, description)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(id)
.bind(tenant_id)
.bind(name)
.bind(description)
.fetch_one(&self.pool)
.await
}
async fn update(
&self,
_id: &str,
_name: &str,
_description: Option<&str>,
id: &str,
name: &str,
description: Option<&str>,
) -> Result<(), sqlx::Error> {
unimplemented!()
sqlx::query(
r#"
UPDATE groups
SET name = $1, description = $2, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
WHERE id = $3
"#,
)
.bind(name)
.bind(description)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, _id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_members(&self, _group_id: &str) -> Result<i64, sqlx::Error> {
unimplemented!()
async fn count_members(&self, group_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_groups WHERE group_id = $1")
.bind(group_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
async fn list_members(&self, _group_id: &str) -> Result<Vec<User>, sqlx::Error> {
unimplemented!()
async fn list_members(&self, group_id: &str) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
r#"
SELECT u.*
FROM users u
JOIN user_groups ug ON u.id = ug.user_id
WHERE ug.group_id = $1
ORDER BY u.username ASC
"#,
)
.bind(group_id)
.fetch_all(&self.pool)
.await
}
async fn add_member(&self, _group_id: &str, _user_id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
async fn add_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
r#"
INSERT INTO user_groups (user_id, group_id)
VALUES ($1, $2)
ON CONFLICT (user_id, group_id) DO NOTHING
"#,
)
.bind(user_id)
.bind(group_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_member(&self, _group_id: &str, _user_id: &str) -> Result<(), sqlx::Error> {
unimplemented!()
async fn remove_member(&self, group_id: &str, user_id: &str) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM user_groups WHERE user_id = $1 AND group_id = $2")
.bind(user_id)
.bind(group_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count(&self, _tenant_id: &str) -> Result<i64, sqlx::Error> {
unimplemented!()
async fn count(&self, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM groups WHERE tenant_id = $1")
.bind(tenant_id)
.fetch_one(&self.pool)
.await?;
Ok(row.0)
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ pub struct PostgresRefreshTokensRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::refresh_tokens::RefreshToken;
use crate::db::models::RefreshToken;
#[async_trait]
impl RefreshTokensRepository for PostgresRefreshTokensRepository {
@@ -50,8 +50,8 @@ impl ServiceAccountsRepository for PostgresServiceAccountsRepository {
async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE service_accounts SET enabled = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
"UPDATE service_accounts SET enabled = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
)
.bind(enabled)
.bind(id)
.execute(&self.pool)
+27 -11
View File
@@ -61,11 +61,11 @@ impl SessionsRepository for PostgresSessionsRepository {
async fn update_last_seen(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE sessions SET last_seen_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
"UPDATE sessions SET last_seen_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
@@ -76,7 +76,7 @@ impl SessionsRepository for PostgresSessionsRepository {
SELECT * FROM sessions
WHERE user_id = $1
AND revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
AND expires_at >= to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
ORDER BY last_seen_at DESC
"#,
)
@@ -86,7 +86,16 @@ impl SessionsRepository for PostgresSessionsRepository {
}
async fn list_all_active(&self) -> Result<Vec<Session>, sqlx::Error> {
unimplemented!()
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE revoked = 0
AND expires_at >= to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
ORDER BY last_seen_at DESC
"#,
)
.fetch_all(&self.pool)
.await
}
/// Count active sessions system-wide.
@@ -95,7 +104,7 @@ impl SessionsRepository for PostgresSessionsRepository {
r#"
SELECT COUNT(*) FROM sessions
WHERE revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
AND expires_at >= to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
"#,
)
.fetch_one(&self.pool)
@@ -109,7 +118,7 @@ impl SessionsRepository for PostgresSessionsRepository {
r#"
DELETE FROM sessions
WHERE revoked = 1
OR expires_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
OR expires_at < to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
"#,
)
.execute(&self.pool)
@@ -117,7 +126,14 @@ impl SessionsRepository for PostgresSessionsRepository {
Ok(result.rows_affected())
}
async fn revoke_others(&self, _user_id: &str, _except_id: &str) -> Result<u64, sqlx::Error> {
unimplemented!()
async fn revoke_others(&self, user_id: &str, except_id: &str) -> Result<u64, sqlx::Error> {
let result = sqlx::query(
"UPDATE sessions SET revoked = 1 WHERE user_id = $1 AND id != $2 AND revoked = 0",
)
.bind(user_id)
.bind(except_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
+2 -2
View File
@@ -69,8 +69,8 @@ impl TokensRepository for PostgresTokensRepository {
async fn update_last_used(&self, id: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $1",
)
"UPDATE api_tokens SET last_used_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
+7 -9
View File
@@ -2,14 +2,12 @@ use crate::db::repository::traits::UsersRepository;
use async_trait::async_trait;
use sqlx::PgPool;
use crate::db::models::User;
use crate::db::models::{User, UserProfile};
pub struct PostgresUsersRepository {
pub pool: PgPool,
}
use crate::db::repository::sqlite::users::UserProfile;
#[async_trait]
impl UsersRepository for PostgresUsersRepository {
async fn count_admins(&self) -> Result<i64, sqlx::Error> {
@@ -91,8 +89,8 @@ impl UsersRepository for PostgresUsersRepository {
async fn update_status(&self, id: &str, status: i32) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET status = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
"UPDATE users SET status = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
)
.bind(status)
.bind(id)
.execute(&self.pool)
@@ -102,8 +100,8 @@ impl UsersRepository for PostgresUsersRepository {
async fn update_password_hash(&self, id: &str, password_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE users SET password_hash = $1, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = $2",
)
"UPDATE users SET password_hash = $1, updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $2",
)
.bind(password_hash)
.bind(id)
.execute(&self.pool)
@@ -113,8 +111,8 @@ impl UsersRepository for PostgresUsersRepository {
async fn set_last_login(&self, 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 = $1",
)
"UPDATE users SET last_login_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), updated_at = to_char(clock_timestamp() AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') WHERE id = $1",
)
.bind(id)
.execute(&self.pool)
.await?;
+61
View File
@@ -0,0 +1,61 @@
use sqlx::SqlitePool;
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct RefreshToken {
pub id: String,
pub user_id: String,
pub token_hash: String,
pub expires_at: String,
pub revoked: bool,
pub created_at: String,
}
pub async fn create(
pool: &SqlitePool,
id: &str,
user_id: &str,
token_hash: &str,
expires_at: &str,
) -> Result<RefreshToken, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
r#"
INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES (?, ?, ?, ?)
RETURNING *
"#,
)
.bind(id)
.bind(user_id)
.bind(token_hash)
.bind(expires_at)
.fetch_one(pool)
.await
}
pub async fn find_by_hash(
pool: &SqlitePool,
token_hash: &str,
) -> Result<Option<RefreshToken>, sqlx::Error> {
sqlx::query_as::<_, RefreshToken>(
"SELECT * FROM refresh_tokens 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 refresh_tokens 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 refresh_tokens SET revoked = 1 WHERE user_id = ?")
.bind(user_id)
.execute(pool)
.await?;
Ok(())
}
+79
View File
@@ -0,0 +1,79 @@
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(())
}
pub async fn delete(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
id: &str,
) -> Result<(), sqlx::Error> {
sqlx::query("DELETE FROM service_accounts WHERE id = ?")
.bind(id)
.execute(&mut **tx)
.await?;
Ok(())
}
pub async fn count(pool: &SqlitePool, tenant_id: &str) -> Result<i64, sqlx::Error> {
let row: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM service_accounts WHERE tenant_id = ?")
.bind(tenant_id)
.fetch_one(pool)
.await?;
Ok(row.0)
}
+112
View File
@@ -0,0 +1,112 @@
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(())
}
/// List active (non-revoked, non-expired) sessions for a user.
pub async fn list_active_for_user(
pool: &SqlitePool,
user_id: &str,
) -> Result<Vec<Session>, sqlx::Error> {
sqlx::query_as::<_, Session>(
r#"
SELECT * FROM sessions
WHERE user_id = ?
AND revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
ORDER BY last_seen_at DESC
"#,
)
.bind(user_id)
.fetch_all(pool)
.await
}
/// Count active sessions system-wide.
pub async fn count_active(pool: &SqlitePool) -> Result<i64, sqlx::Error> {
let row: (i64,) = sqlx::query_as(
r#"
SELECT COUNT(*) FROM sessions
WHERE revoked = 0
AND expires_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
"#,
)
.fetch_one(pool)
.await?;
Ok(row.0)
}
/// 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())
}
+1 -15
View File
@@ -2,26 +2,12 @@ use crate::db::repository::traits::AuditRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::AuditLog;
use crate::db::models::{AuditFilter, AuditLog};
pub struct SqliteAuditRepository {
pub pool: SqlitePool,
}
/// Filtered audit log query. All filters are optional.
#[derive(Debug, Default)]
pub struct AuditFilter {
pub actor_user_id: Option<String>,
pub action: Option<String>,
pub resource_type: Option<String>,
pub severity: Option<String>,
pub since: Option<String>,
pub until: Option<String>,
pub search: Option<String>,
pub limit: i64,
pub offset: i64,
}
#[async_trait]
impl AuditRepository for SqliteAuditRepository {
/// Count all audit log entries.
+1 -9
View File
@@ -6,15 +6,7 @@ pub struct SqliteRefreshTokensRepository {
pub pool: SqlitePool,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct RefreshToken {
pub id: String,
pub user_id: String,
pub token_hash: String,
pub expires_at: String,
pub revoked: bool,
pub created_at: String,
}
use crate::db::models::RefreshToken;
#[async_trait]
impl RefreshTokensRepository for SqliteRefreshTokensRepository {
+1 -11
View File
@@ -2,22 +2,12 @@ use crate::db::repository::traits::UsersRepository;
use async_trait::async_trait;
use sqlx::SqlitePool;
use crate::db::models::User;
use crate::db::models::{User, UserProfile};
pub struct SqliteUsersRepository {
pub pool: SqlitePool,
}
/// User profile fields from `user_profiles`.
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
pub struct UserProfile {
pub user_id: String,
pub email: Option<String>,
pub full_name: Option<String>,
pub avatar_url: Option<String>,
pub metadata_json: Option<String>,
}
#[async_trait]
impl UsersRepository for SqliteUsersRepository {
/// Count users with a given status in a tenant.
-2
View File
@@ -1,5 +1,3 @@
pub use crate::db::repository::sqlite::tokens::*;
use crate::db::models::ApiToken;
use crate::db::provider::DatabaseProvider;
use std::sync::Arc;
+2 -4
View File
@@ -1,9 +1,7 @@
use crate::db::models::{
ApiToken, Application, AuditLog, Group, Permission, Role, ServiceAccount, Session, Tenant, User,
ApiToken, Application, AuditFilter, AuditLog, Group, Permission, RefreshToken, Role,
ServiceAccount, Session, Tenant, User, UserProfile,
};
use crate::db::repository::sqlite::audit::AuditFilter;
use crate::db::repository::sqlite::refresh_tokens::RefreshToken;
use crate::db::repository::sqlite::users::UserProfile;
#[async_trait::async_trait]
pub trait UsersRepository: Send + Sync {
+7 -2
View File
@@ -157,10 +157,15 @@ pub async fn reset_password(
audit_ip: Option<&str>,
audit_ua: Option<&str>,
) -> Result<(), AppError> {
let user = provider.users().find_by_id(user_id).await?;
let user = provider
.users()
.find_by_id(user_id)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound)?;
let user_roles = provider
.roles()
.list_for_user(&user.unwrap().id)
.list_for_user(&user.id)
.await
.map_err(AppError::Database)?;
let is_admin = user_roles.iter().any(|r| r.name == "admin");
+1
View File
@@ -6,5 +6,6 @@ pub mod db;
pub mod error;
pub mod identity;
pub mod middleware;
pub mod runtime;
pub mod security;
pub mod state;
+4 -46
View File
@@ -1,14 +1,10 @@
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,
state::AppState,
runtime::{Application, Lifecycle},
};
#[tokio::main]
@@ -105,46 +101,8 @@ async fn main() -> anyhow::Result<()> {
}
}
/// Start the HTTP server (Milestone B+).
/// Start the HTTP server using the runtime lifecycle.
async fn run_server(config: Config) -> anyhow::Result<()> {
// Refuse insecure production configuration (Secure cookies / HSTS surface).
config.server.validate_production_security()?;
// Open DB pool and run migrations
let pool = db::create_pool(&config.database.path).await?;
db::run_migrations(&pool).await?;
let pool_clone = pool.clone();
tokio::spawn(async move {
let _ = pool_clone; // TODO: restore session repo cleanup logic using the new provider architecture
});
let provider: std::sync::Arc<dyn db::provider::DatabaseProvider> =
std::sync::Arc::new(db::provider::SqliteProvider::new(pool));
let state = AppState::new(provider.clone(), config.clone());
let app = api::router::build(state);
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!(
"\nnx9-auth is running\n\n API + Admin UI : http://{}\n Health check : http://{}/health\n",
addr, addr
);
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
let mut app = Application::builder(config).build().await?;
app.start().await
}
+3
View File
@@ -32,6 +32,9 @@ pub async fn security_headers(
HeaderValue::from_static("no-referrer"),
);
// Prevent sensitive state caching across browsers and intermediaries
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
// SPA + same-origin API CSP.
// 'wasm-unsafe-eval' is required for WebAssembly instantiation in Chromium.
headers.insert(
+232
View File
@@ -0,0 +1,232 @@
//! Runtime application container.
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use crate::config::Config;
use crate::db::PoolHandle;
use crate::db::provider::DatabaseProvider;
use super::{
AtomicRuntimeState, HookRegistry, Lifecycle, RuntimeMetrics, RuntimeState, ShutdownCoordinator,
SignalManager, WorkerManager, signals,
};
/// Unified runtime application container that manages state transitions,
/// database connections, router setup, HTTP server execution, background workers,
/// metrics, and graceful shutdown hooks.
#[derive(Default)]
pub struct Application {
pub config: Option<Config>,
pub provider: Option<Arc<dyn DatabaseProvider>>,
pub pool_handle: Option<PoolHandle>,
pub router: Option<axum::Router>,
pub state: AtomicRuntimeState,
pub hooks: HookRegistry,
pub workers: WorkerManager,
pub signals: SignalManager,
pub shutdown: ShutdownCoordinator,
pub metrics: RuntimeMetrics,
}
impl Application {
/// Create a new application runtime.
pub fn new() -> Self {
Self::default()
}
/// Create a builder for a runtime application initialized from config.
pub fn builder(config: Config) -> super::ApplicationBuilder {
super::ApplicationBuilder::new().with_config(config)
}
/// Read the current runtime state.
pub fn state(&self) -> RuntimeState {
self.state.load()
}
/// Access the shutdown hook registry.
pub fn hooks(&self) -> &HookRegistry {
&self.hooks
}
/// Mutably access the shutdown hook registry.
pub fn hooks_mut(&mut self) -> &mut HookRegistry {
&mut self.hooks
}
/// Access the worker manager.
pub fn workers(&self) -> &WorkerManager {
&self.workers
}
/// Mutably access the worker manager.
pub fn workers_mut(&mut self) -> &mut WorkerManager {
&mut self.workers
}
/// Access the signal manager.
pub fn signals(&self) -> &SignalManager {
&self.signals
}
/// Access the shutdown coordinator.
pub fn shutdown_coordinator(&self) -> &ShutdownCoordinator {
&self.shutdown
}
/// Access runtime metrics.
pub fn metrics(&self) -> &RuntimeMetrics {
&self.metrics
}
/// Force a runtime state update.
pub fn set_state(&self, state: RuntimeState) {
self.state.force_set(state);
}
/// Perform graceful shutdown flow explicitly.
pub async fn perform_shutdown(&mut self) -> Result<()> {
if !self.state.initiate_shutdown() {
if self.state.load().is_shutting_down() {
return Ok(());
}
self.state.force_set(RuntimeState::Draining);
}
println!("Draining");
tracing::info!("draining active connections");
let _ = self
.state
.transition(RuntimeState::Draining, RuntimeState::StoppingWorkers);
println!("StoppingWorkers");
tracing::info!("stopping background workers");
self.workers.shutdown_all(Duration::from_secs(10)).await;
let _ = self
.state
.transition(RuntimeState::StoppingWorkers, RuntimeState::ExecutingHooks);
println!("ExecutingHooks");
tracing::info!("executing shutdown hooks");
self.hooks.execute_all().await;
let _ = self
.state
.transition(RuntimeState::ExecutingHooks, RuntimeState::ClosingResources);
println!("ClosingResources");
tracing::info!("closing database connection pool and resources");
if let Some(pool) = self.pool_handle.take() {
pool.close().await;
}
let _ = self
.state
.transition(RuntimeState::ClosingResources, RuntimeState::Stopped);
println!("Stopped");
tracing::info!("application stopped cleanly");
Ok(())
}
}
#[async_trait::async_trait]
impl Lifecycle for Application {
async fn initialize(&mut self) -> Result<()> {
println!("Initializing");
let _ = self
.state
.transition(RuntimeState::Initializing, RuntimeState::Starting);
println!("Starting");
let config = match &self.config {
Some(cfg) => cfg.clone(),
None => {
let mut cfg = Config::default();
cfg.resolve_paths();
self.config = Some(cfg.clone());
cfg
}
};
if self.provider.is_none() {
let (provider, _backend, pool_handle) = crate::db::init_provider(&config).await?;
self.provider = Some(provider);
self.pool_handle = Some(pool_handle);
}
if self.router.is_none() {
if let Some(provider) = &self.provider {
let app_state = crate::state::AppState::new(provider.clone(), config);
let router = crate::api::router::build(app_state);
self.router = Some(router);
}
}
Ok(())
}
async fn start(&mut self) -> Result<()> {
if self.state.load() == RuntimeState::Initializing {
self.initialize().await?;
}
if self.state.load() == RuntimeState::Starting {
let _ = self
.state
.transition(RuntimeState::Starting, RuntimeState::Running);
}
println!("Running");
let config = self.config.as_ref().cloned().unwrap_or_default();
let addr_str = format!("{}:{}", config.server.host, config.server.port);
let listener = tokio::net::TcpListener::bind(&addr_str)
.await
.with_context(|| format!("failed to bind TCP listener to {addr_str}"))?;
let local_addr = listener.local_addr()?;
println!("Listening on {}", local_addr);
tracing::info!(address = %local_addr, "Listening on {}", local_addr);
let router = match self.router.take() {
Some(r) => r,
None => {
let provider = self
.provider
.clone()
.context("database provider not initialized")?;
let app_state = crate::state::AppState::new(provider, config);
crate::api::router::build(app_state)
}
};
let signal_mgr = self.signals.clone();
let shutdown_coord = self.shutdown.clone();
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
tokio::select! {
sig = signals::wait_for_shutdown_signal() => {
tracing::info!(signal = sig, "received shutdown signal");
signal_mgr.record_signal();
shutdown_coord.cancel();
}
_ = shutdown_coord.cancelled() => {
tracing::info!("shutdown coordinator cancelled");
}
}
});
if let Err(err) = server.await {
tracing::error!(error = %err, "HTTP server error");
}
self.perform_shutdown().await
}
async fn shutdown(&mut self) -> Result<()> {
self.perform_shutdown().await
}
}
+41
View File
@@ -0,0 +1,41 @@
//! Application builder helpers.
use crate::config::Config;
use super::{Application, Lifecycle};
/// Small builder façade over the runtime application container.
#[derive(Default)]
pub struct ApplicationBuilder {
config: Option<Config>,
application: Option<Application>,
}
impl ApplicationBuilder {
/// Create a new builder instance.
pub fn new() -> Self {
Self::default()
}
/// Attach config used to initialize the application.
pub fn with_config(mut self, config: Config) -> Self {
self.config = Some(config);
self
}
/// Override the application instance before building.
pub fn with_application(mut self, application: Application) -> Self {
self.application = Some(application);
self
}
/// Build the runtime application.
pub async fn build(self) -> anyhow::Result<Application> {
let mut application = self.application.unwrap_or_default();
if let Some(config) = self.config {
application.config = Some(config);
}
application.initialize().await?;
Ok(application)
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Runtime metrics and operational counters.
/// Lightweight runtime metrics storage used by the runtime layer.
#[derive(Debug, Clone, Default)]
pub struct RuntimeMetrics {
requests_total: u64,
errors_total: u64,
active_workers: usize,
}
impl RuntimeMetrics {
/// Create a new metrics container.
pub fn new() -> Self {
Self::default()
}
/// Record a completed request.
pub fn record_request(&mut self) {
self.requests_total += 1;
}
/// Record a runtime error.
pub fn record_error(&mut self) {
self.errors_total += 1;
}
/// Update the current number of active workers.
pub fn set_active_workers(&mut self, count: usize) {
self.active_workers = count;
}
/// Return the total number of processed requests.
pub fn requests_total(&self) -> u64 {
self.requests_total
}
/// Return the total number of runtime errors.
pub fn errors_total(&self) -> u64 {
self.errors_total
}
/// Return the current worker count.
pub fn active_workers(&self) -> usize {
self.active_workers
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
//! Unix signal handling for graceful and forced shutdown.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[derive(Clone)]
pub struct SignalManager {
+2 -4
View File
@@ -88,8 +88,7 @@ impl AtomicRuntimeState {
/// Read the current state (acquire ordering for visibility).
pub fn load(&self) -> RuntimeState {
RuntimeState::from_u8(self.state.load(Ordering::Acquire))
.unwrap_or(RuntimeState::Stopped)
RuntimeState::from_u8(self.state.load(Ordering::Acquire)).unwrap_or(RuntimeState::Stopped)
}
/// Attempt an atomic state transition from `expected` to `new`.
@@ -112,8 +111,7 @@ impl AtomicRuntimeState {
Ok(new)
}
Err(actual) => {
let actual_state =
RuntimeState::from_u8(actual).unwrap_or(RuntimeState::Stopped);
let actual_state = RuntimeState::from_u8(actual).unwrap_or(RuntimeState::Stopped);
tracing::debug!(
expected = %expected,
actual = %actual_state,