//! Explicit Phase 3 Identity & Directory Migration Engine. //! //! Lifecycle: //! 1. Preflight (inspect DB and filesystem, identify unmigrated users) //! 2. Backup (create pre-migration tarball) //! 3. Identity Migration (assign immutable TenantId + UUID in users.db) //! 4. Filesystem Migration (atomically move users// to users//) //! 5. Validation (verify all users, directories, and databases) //! 6. Completion Marker (record audit event and system setting) //! //! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted //! to a normal TenantId directory. use rusqlite::Connection; use std::fs; use std::path::PathBuf; use tracing::{info, warn}; use crate::config::Config; use crate::db::topology::{is_v08_user_id, Topology}; use crate::db::Db; use crate::identity::TenantId; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct IdentityMigrationReport { pub total_users: usize, pub users_assigned_tenant_id: usize, pub users_assigned_uuid: usize, pub directories_moved: usize, pub directories_already_migrated: usize, pub legacy_admin_preserved: bool, pub validation_passed: bool, pub warnings: Vec, } #[derive(Debug, Clone)] pub struct PreflightPlan { pub total_users: usize, pub normal_users: usize, pub users_needing_tenant_id: Vec<(i64, String)>, pub users_needing_uuid: Vec<(i64, String)>, pub directories_to_move: Vec<(i64, PathBuf, TenantId)>, pub directories_already_migrated: usize, } /// Run the full explicit identity migration lifecycle. pub async fn run_identity_migration( config: &Config, dry_run: bool, skip_backup: bool, ) -> Result> { let topology = Topology::new(&config.data_dir); let mut warnings = Vec::new(); // 1. Initialize Db for Core connections let db = Db::init(config)?; // 2. Preflight let plan = { let users_conn = db.users.lock().unwrap(); inspect_preflight(&users_conn, &topology)? }; info!( "Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}", plan.total_users, plan.normal_users, plan.users_needing_tenant_id.len(), plan.users_needing_uuid.len(), plan.directories_to_move.len() ); if dry_run { info!("Dry run mode enabled. No identity changes or directory moves will be performed."); return Ok(IdentityMigrationReport { total_users: plan.total_users, users_assigned_tenant_id: plan.users_needing_tenant_id.len(), users_assigned_uuid: plan.users_needing_uuid.len(), directories_moved: plan.directories_to_move.len(), directories_already_migrated: plan.directories_already_migrated, legacy_admin_preserved: true, validation_passed: true, warnings, }); } // 3. Backup before migration (if there is work to do and backup is not skipped) let needs_migration = !plan.users_needing_tenant_id.is_empty() || !plan.users_needing_uuid.is_empty() || !plan.directories_to_move.is_empty(); if needs_migration && !skip_backup { info!("Creating pre-migration backup..."); match crate::jobs::backup::perform_backup(&db, config).await { Ok(path) => info!("Pre-migration backup created at {:?}", path), Err(e) => { warn!( "Pre-migration backup failed: {}. Continuing with caution.", e ); warnings.push(format!("Pre-migration backup warning: {e}")); } } } // 4. Identity Migration (users.db backfill) let (assigned_tids, assigned_uuids) = { let mut users_conn = db.users.lock().unwrap(); migrate_user_table_identities(&mut users_conn)? }; // 5. Filesystem Migration (users// -> users//) let moved_dirs = { let users_conn = db.users.lock().unwrap(); migrate_tenant_directories(&users_conn, &topology, &mut warnings)? }; // 6. Validation let validation_passed = { let users_conn = db.users.lock().unwrap(); validate_identity_migration(&users_conn, &topology, &mut warnings)? }; // 7. Completion Marker in system.db if validation_passed { let system_conn = db.system.lock().unwrap(); let now = chrono::Utc::now().to_rfc3339(); let details = format!( "Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved", assigned_tids, assigned_uuids, moved_dirs ); let _ = crate::db::audit_events::write_audit_event( &system_conn, "system", "IDENTITY_MIGRATION_COMPLETED", "identity", "users.db", Some(&details), ); let _ = system_conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);", [&now], ); } Ok(IdentityMigrationReport { total_users: plan.total_users, users_assigned_tenant_id: assigned_tids, users_assigned_uuid: assigned_uuids, directories_moved: moved_dirs, directories_already_migrated: plan.directories_already_migrated, legacy_admin_preserved: true, validation_passed, warnings, }) } /// Inspect existing database and filesystem to build a preflight plan. pub fn inspect_preflight( users_conn: &Connection, topology: &Topology, ) -> Result> { let users = crate::db::users::list_users(users_conn)?; let total_users = users.len(); let mut normal_users = 0; let mut users_needing_tenant_id = Vec::new(); let mut users_needing_uuid = Vec::new(); let mut directories_to_move = Vec::new(); let mut directories_already_migrated = 0; for user in &users { // Skip legacy admin / system accounts if user.account_type == "admin" || user.account_type == "system" || user.username == "legacy_admin" { continue; } normal_users += 1; if user.tenant_id.is_none() { users_needing_tenant_id.push((user.id, user.username.clone())); } if user.uuid.is_none() { users_needing_uuid.push((user.id, user.username.clone())); } if let Some(tid) = user.tenant_id { let legacy_dir = topology.user_dir_i64(user.id)?; let target_dir = topology.tenant_dir(tid); if legacy_dir.exists() && legacy_dir != target_dir { directories_to_move.push((user.id, legacy_dir, tid)); } else if target_dir.exists() { directories_already_migrated += 1; } } } Ok(PreflightPlan { total_users, normal_users, users_needing_tenant_id, users_needing_uuid, directories_to_move, directories_already_migrated, }) } /// Assign immutable TenantId and UUID for all normal users missing them in users.db. /// Restart-safe: never regenerates or modifies existing identities. pub fn migrate_user_table_identities( users_conn: &mut Connection, ) -> Result<(usize, usize), Box> { let users = crate::db::users::list_users(users_conn)?; let mut assigned_tids = 0; let mut assigned_uuids = 0; let tx = users_conn.transaction()?; for user in users { // Skip legacy admin / system accounts if user.account_type == "admin" || user.account_type == "system" || user.username == "legacy_admin" { continue; } let mut needs_update = false; let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string()); let mut uuid_str = user.uuid; if tid_str.is_none() { // Allocate unique TenantId let tid = crate::identity::TenantId::generate(); tid_str = Some(tid.as_str().to_string()); assigned_tids += 1; needs_update = true; } if uuid_str.is_none() { // Allocate unique UUID let u = uuid::Uuid::new_v4().to_string(); uuid_str = Some(u); assigned_uuids += 1; needs_update = true; } if needs_update { tx.execute( "UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;", rusqlite::params![tid_str, uuid_str, user.id], )?; } } tx.commit()?; Ok((assigned_tids, assigned_uuids)) } /// Move tenant directories from users// to users// for normal users. /// Legacy users/1 is preserved. pub fn migrate_tenant_directories( users_conn: &Connection, topology: &Topology, warnings: &mut Vec, ) -> Result> { let users = crate::db::users::list_users(users_conn)?; let mut moved = 0; for user in users { if user.account_type == "admin" || user.account_type == "system" || user.username == "legacy_admin" { // Preserve Core / system accounts intact continue; } let Some(tid) = user.tenant_id else { warnings.push(format!( "User '{}' (ID {}) has no TenantId; skipping directory move", user.username, user.id )); continue; }; let legacy_dir = match topology.user_dir_i64(user.id) { Ok(d) => d, Err(_) => continue, }; let target_dir = topology.tenant_dir(tid); if legacy_dir.exists() && legacy_dir != target_dir { if !target_dir.exists() { // Atomic rename on same filesystem fs::rename(&legacy_dir, &target_dir)?; let _ = fs::create_dir_all(target_dir.join("extensions")); info!( "Migrated tenant directory for user '{}': {:?} -> {:?}", user.username, legacy_dir, target_dir ); moved += 1; } else { // Target already exists (interrupted / partial run) warn!( "Target directory {:?} already exists for user '{}'. Verifying contents.", target_dir, user.username ); // Ensure extensions dir exists let _ = fs::create_dir_all(target_dir.join("extensions")); // If legacy directory is now empty or duplicate, clean it up safely if let Ok(entries) = fs::read_dir(&legacy_dir) { if entries.count() == 0 { let _ = fs::remove_dir(&legacy_dir); } } } } } Ok(moved) } /// Validate that every normal user has a valid TenantId, UUID, and matching directory. pub fn validate_identity_migration( users_conn: &Connection, topology: &Topology, warnings: &mut Vec, ) -> Result> { let users = crate::db::users::list_users(users_conn)?; let mut valid = true; for user in &users { if user.account_type == "admin" || user.account_type == "system" || user.username == "legacy_admin" { continue; } // Validate TenantId match user.tenant_id { Some(tid) => { if !is_v08_user_id(tid.as_str()) { warnings.push(format!( "Invalid TenantId format '{}' for user '{}'", tid.as_str(), user.username )); valid = false; } let target_dir = topology.tenant_dir(tid); if !target_dir.exists() { // Check if content db was initialized or if directory was not yet created warnings.push(format!( "Tenant directory {:?} does not exist for user '{}'", target_dir, user.username )); } } None => { warnings.push(format!( "Normal user '{}' (ID {}) is missing TenantId", user.username, user.id )); valid = false; } } // Validate UUID match &user.uuid { Some(u) => { if uuid::Uuid::parse_str(u).is_err() { warnings.push(format!( "Invalid UUID format '{}' for user '{}'", u, user.username )); valid = false; } } None => { warnings.push(format!( "Normal user '{}' (ID {}) is missing UUID", user.username, user.id )); valid = false; } } } Ok(valid) }