auth_framework/migrations/
mod.rs1#[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 pub async fn migrate(&self) -> Result<(), sqlx::Error> {
22 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#[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 pub async fn migrate(&mut self) -> Result<(), MigrationError> {
63 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 tx.batch_execute(&migration.sql).await?;
231
232 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 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 pub fn create_migration(version: i64, name: String, sql: String) -> Migration {
270 Migration { version, name, sql }
271 }
272
273 pub fn list_available_migrations(&self) -> Vec<Migration> {
275 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"))]
307pub 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}