Skip to main content

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