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> {
20 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#[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 pub async fn migrate(&mut self) -> Result<(), MigrationError> {
61 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 tx.batch_execute(&migration.sql).await?;
229
230 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 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 pub fn create_migration(version: i64, name: String, sql: String) -> Migration {
268 Migration { version, name, sql }
269 }
270
271 pub fn list_available_migrations(&self) -> Vec<Migration> {
273 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"))]
305pub 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