auth_framework/migrations/
mod.rs

1//! Database migration system for auth-framework.
2//! This module provides tools for managing database schema changes
3//! and ensuring proper setup of authentication-related tables.
4#[cfg(feature = "mysql-storage")]
5use sqlx::MySqlPool;
6
7#[cfg(feature = "mysql-storage")]
8pub struct MySqlMigrationManager {
9    pool: MySqlPool,
10}
11
12#[cfg(feature = "mysql-storage")]
13impl MySqlMigrationManager {
14    pub fn new(pool: MySqlPool) -> Self {
15        Self { pool }
16    }
17
18    /// Run all pending migrations (stub)
19    pub async fn migrate(&self) -> Result<(), sqlx::Error> {
20        // Example: create users table if not exists
21        sqlx::query(
22            r#"CREATE TABLE IF NOT EXISTS users (
23                id VARCHAR(36) PRIMARY KEY,
24                username VARCHAR(255) NOT NULL,
25                password_hash VARCHAR(255) NOT NULL,
26                email VARCHAR(255),
27                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
28            )"#,
29        )
30        .execute(&self.pool)
31        .await?;
32        Ok(())
33    }
34}
35
36#[cfg(any(feature = "cli", feature = "postgres-storage"))]
37use tokio_postgres::{Client, Error as PgError};
38
39/// Migration manager for database schema changes
40#[cfg(any(feature = "cli", feature = "postgres-storage"))]
41pub struct MigrationManager {
42    client: Client,
43}
44
45#[cfg(any(feature = "cli", feature = "postgres-storage"))]
46#[derive(Debug, Clone)]
47pub struct Migration {
48    pub version: i64,
49    pub name: String,
50    pub sql: String,
51}
52
53#[cfg(any(feature = "cli", feature = "postgres-storage"))]
54impl MigrationManager {
55    pub fn new(client: Client) -> Self {
56        Self { client }
57    }
58
59    /// Run all pending migrations
60    pub async fn migrate(&mut self) -> Result<(), MigrationError> {
61        // Ensure migrations table exists
62        self.ensure_migrations_table().await?;
63
64        let applied = self.get_applied_migrations().await?;
65        let available = self.get_available_migrations();
66
67        for migration in available {
68            if !applied.contains(&migration.version) {
69                println!("Applying migration: {}", migration.name);
70                self.apply_migration(&migration).await?;
71            }
72        }
73
74        Ok(())
75    }
76
77    async fn ensure_migrations_table(&self) -> Result<(), PgError> {
78        self.client
79            .execute(
80                r#"
81            CREATE TABLE IF NOT EXISTS _auth_migrations (
82                version BIGINT PRIMARY KEY,
83                name VARCHAR(255) NOT NULL,
84                applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
85            )
86            "#,
87                &[],
88            )
89            .await?;
90        Ok(())
91    }
92
93    async fn get_applied_migrations(&self) -> Result<Vec<i64>, PgError> {
94        let rows = self
95            .client
96            .query("SELECT version FROM _auth_migrations ORDER BY version", &[])
97            .await?;
98
99        Ok(rows.iter().map(|row| row.get(0)).collect())
100    }
101
102    fn get_available_migrations(&self) -> Vec<Migration> {
103        vec![
104            Migration {
105                version: 1,
106                name: "create_users_table".to_string(),
107                sql: r#"
108                    CREATE TABLE IF NOT EXISTS users (
109                        id VARCHAR(36) PRIMARY KEY,
110                        username VARCHAR(255) UNIQUE NOT NULL,
111                        password_hash VARCHAR(255) NOT NULL,
112                        email VARCHAR(255) UNIQUE,
113                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
114                        updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
115                        is_active BOOLEAN DEFAULT true,
116                        last_login TIMESTAMP WITH TIME ZONE
117                    );
118                    CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
119                    CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
120                "#.to_string(),
121            },
122            Migration {
123                version: 2,
124                name: "create_roles_permissions".to_string(),
125                sql: r#"
126                    CREATE TABLE IF NOT EXISTS roles (
127                        id VARCHAR(36) PRIMARY KEY,
128                        name VARCHAR(100) UNIQUE NOT NULL,
129                        description TEXT,
130                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
131                    );
132
133                    CREATE TABLE IF NOT EXISTS permissions (
134                        id VARCHAR(36) PRIMARY KEY,
135                        action VARCHAR(100) NOT NULL,
136                        resource VARCHAR(100) NOT NULL,
137                        instance VARCHAR(100),
138                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
139                    );
140
141                    CREATE TABLE IF NOT EXISTS user_roles (
142                        user_id VARCHAR(36) REFERENCES users(id),
143                        role_id VARCHAR(36) REFERENCES roles(id),
144                        PRIMARY KEY (user_id, role_id)
145                    );
146
147                    CREATE TABLE IF NOT EXISTS role_permissions (
148                        role_id VARCHAR(36) REFERENCES roles(id),
149                        permission_id VARCHAR(36) REFERENCES permissions(id),
150                        PRIMARY KEY (role_id, permission_id)
151                    );
152                "#.to_string(),
153            },
154            Migration {
155                version: 3,
156                name: "create_sessions_table".to_string(),
157                sql: r#"
158                    CREATE TABLE IF NOT EXISTS sessions (
159                        id VARCHAR(36) PRIMARY KEY,
160                        user_id VARCHAR(36) REFERENCES users(id),
161                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
162                        last_accessed TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
163                        expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
164                        state VARCHAR(20) DEFAULT 'active',
165                        device_fingerprint TEXT,
166                        ip_address INET,
167                        user_agent TEXT,
168                        security_flags TEXT[]
169                    );
170                    CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
171                    CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
172                "#.to_string(),
173            },
174            Migration {
175                version: 4,
176                name: "create_audit_logs".to_string(),
177                sql: r#"
178                    CREATE TABLE IF NOT EXISTS audit_logs (
179                        id VARCHAR(36) PRIMARY KEY,
180                        event_type VARCHAR(50) NOT NULL,
181                        timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
182                        user_id VARCHAR(36),
183                        session_id VARCHAR(36),
184                        resource VARCHAR(100),
185                        action VARCHAR(100),
186                        success BOOLEAN NOT NULL,
187                        ip_address INET,
188                        user_agent TEXT,
189                        details JSONB
190                    );
191                    CREATE INDEX IF NOT EXISTS idx_audit_logs_timestamp ON audit_logs(timestamp);
192                    CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
193                    CREATE INDEX IF NOT EXISTS idx_audit_logs_event_type ON audit_logs(event_type);
194                "#.to_string(),
195            },
196            Migration {
197                version: 5,
198                name: "create_mfa_table".to_string(),
199                sql: r#"
200                    CREATE TABLE IF NOT EXISTS mfa_secrets (
201                        user_id VARCHAR(36) PRIMARY KEY REFERENCES users(id),
202                        totp_secret VARCHAR(255),
203                        backup_codes TEXT[],
204                        phone_number VARCHAR(20),
205                        email_verified BOOLEAN DEFAULT false,
206                        phone_verified BOOLEAN DEFAULT false,
207                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
208                    );
209
210                    CREATE TABLE IF NOT EXISTS mfa_challenges (
211                        id VARCHAR(36) PRIMARY KEY,
212                        user_id VARCHAR(36) REFERENCES users(id),
213                        challenge_type VARCHAR(20) NOT NULL,
214                        challenge_data TEXT,
215                        expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
216                        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
217                    );
218                    CREATE INDEX IF NOT EXISTS idx_mfa_challenges_expires_at ON mfa_challenges(expires_at);
219                "#.to_string(),
220            },
221        ]
222    }
223
224    async fn apply_migration(&mut self, migration: &Migration) -> Result<(), MigrationError> {
225        let tx = self.client.transaction().await?;
226
227        // Execute migration SQL
228        tx.batch_execute(&migration.sql).await?;
229
230        // Record migration
231        use tokio_postgres::types::ToSql;
232        tx.execute(
233            "INSERT INTO _auth_migrations (version, name) VALUES ($1, $2)",
234            &[
235                &migration.version as &(dyn ToSql + Sync),
236                &migration.name.as_str() as &(dyn ToSql + Sync),
237            ],
238        )
239        .await?;
240
241        tx.commit().await?;
242        Ok(())
243    }
244
245    /// Check migration status
246    pub async fn status(&self) -> Result<MigrationStatus, MigrationError> {
247        let applied = self
248            .get_applied_migrations()
249            .await
250            .map_err(MigrationError::Database)?;
251        let available = self.get_available_migrations();
252
253        let pending: Vec<_> = available
254            .iter()
255            .filter(|m| !applied.contains(&m.version))
256            .collect();
257
258        Ok(MigrationStatus {
259            applied_count: applied.len(),
260            pending_count: pending.len(),
261            latest_applied: applied.last().copied(),
262            next_pending: pending.first().map(|m| m.version),
263        })
264    }
265
266    /// Create a new migration (for external use)
267    pub fn create_migration(version: i64, name: String, sql: String) -> Migration {
268        Migration { version, name, sql }
269    }
270
271    /// Get list of available migrations
272    pub fn list_available_migrations(&self) -> Vec<Migration> {
273        // Return cloned migrations to avoid lifetime issues
274        self.get_available_migrations()
275    }
276}
277
278#[derive(Debug)]
279pub struct MigrationStatus {
280    pub applied_count: usize,
281    pub pending_count: usize,
282    pub latest_applied: Option<i64>,
283    pub next_pending: Option<i64>,
284}
285
286#[cfg(any(feature = "cli", feature = "postgres-storage"))]
287#[derive(Debug, thiserror::Error)]
288pub enum MigrationError {
289    #[error("Database error: {0}")]
290    Database(PgError),
291    #[error("Migration not found: {0}")]
292    NotFound(i64),
293    #[error("Invalid migration order")]
294    InvalidOrder,
295}
296
297#[cfg(any(feature = "cli", feature = "postgres-storage"))]
298impl From<PgError> for MigrationError {
299    fn from(e: PgError) -> Self {
300        MigrationError::Database(e)
301    }
302}
303
304#[cfg(any(feature = "cli", feature = "postgres-storage"))]
305/// CLI tool for running migrations
306pub struct MigrationCli;
307
308#[cfg(any(feature = "cli", feature = "postgres-storage"))]
309impl MigrationCli {
310    pub async fn run(database_url: &str, command: &str) -> Result<(), Box<dyn std::error::Error>> {
311        let (client, connection) =
312            tokio_postgres::connect(database_url, tokio_postgres::NoTls).await?;
313        tokio::spawn(async move {
314            if let Err(e) = connection.await {
315                eprintln!("Connection error: {}", e);
316            }
317        });
318        let mut manager = MigrationManager::new(client);
319
320        match command {
321            "migrate" => {
322                manager.migrate().await?;
323                println!("Migrations completed successfully");
324            }
325            "status" => {
326                let status = manager.status().await?;
327                println!("Migration Status:");
328                println!("  Applied: {}", status.applied_count);
329                println!("  Pending: {}", status.pending_count);
330                if let Some(latest) = status.latest_applied {
331                    println!("  Latest Applied: {}", latest);
332                }
333                if let Some(next) = status.next_pending {
334                    println!("  Next Pending: {}", next);
335                }
336            }
337            _ => {
338                eprintln!("Unknown command: {}", command);
339                eprintln!("Available commands: migrate, status");
340            }
341        }
342
343        Ok(())
344    }
345}
346
347