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

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