auth_logging.rs
141 lignes · 4933 octets
use crate::error::AppError; use crate::hooks::{AuditEvent, RustwardenHooks}; use crate::models::audit_log; use chrono::Utc; use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; use std::sync::Arc; use uuid::Uuid; /// Logging service for blocked authentication attempts. /// /// Goals: /// - Record blocked attempts (reason, email, timestamp) /// - Never store secrets or passwords /// - Never fail the main authentication flow if logging fails /// - Notify consumers via `RustwardenHooks::on_audit_event` /// when they have plugged in a sink (SIEM, Langfuse, etc.) pub struct AuthLoggingService; impl AuthLoggingService { /// Log a blocked login attempt (unverified email, etc.). /// /// When `hooks` is provided, also emits an `AuditEvent { kind: "auth.login_blocked" }` /// so that consumer applications can aggregate all audit data in a /// single pipeline without re-reading the `audit_logs` table. pub async fn log_blocked_login( db: &DatabaseConnection, email: &str, reason: &str, hooks: Option<&Arc<dyn RustwardenHooks>>, ) -> Result<(), AppError> { tracing::warn!("[LOGIN_BLOCKED] email={} reason={}", email, reason); // Insère dans audit_logs si disponible (best-effort) let log = audit_log::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(None), action: Set("login_blocked".to_string()), target_user_id: Set(None), level: Set("WARN".to_string()), message: Set(format!("email={} reason={}", email, reason)), created_at: Set(Utc::now()), actor_remote_identity_id: Set(None), target_remote_identity_id: Set(None), }; if let Err(e) = log.insert(db).await { tracing::warn!("Impossible d’écrire le log d’auth: {}", e); } // Fan-out au hook generique si le consommateur en a branché un. // Un échec du hook ne doit pas casser le flux d'auth (best-effort). if let Some(hooks) = hooks { let metadata = serde_json::json!({ "email": email, "reason": reason }); let event = AuditEvent { kind: "auth.login_blocked", actor_id: None, target: None, metadata: &metadata, }; if let Err(e) = hooks.on_audit_event(db, event).await { tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::hooks::RustwardenHooks; use async_trait::async_trait; use std::sync::Mutex; /// Hooks impl that forwards every recorded event to an externally-owned /// `Arc<Mutex<Vec<_>>>`. Keeping the sink outside the trait object avoids /// any downcast when the `Arc<dyn RustwardenHooks>` is passed downstream. struct CapturingHooks { sink: Arc<Mutex<Vec<(String, serde_json::Value)>>>, } #[async_trait] impl RustwardenHooks for CapturingHooks { async fn on_audit_event( &self, _db: &DatabaseConnection, event: AuditEvent<'_>, ) -> anyhow::Result<()> { self.sink .lock() .unwrap() .push((event.kind.to_string(), event.metadata.clone())); Ok(()) } } /// Per-test connection — see personal_token_service::tests for rationale. async fn test_db() -> DatabaseConnection { dotenvy::dotenv().ok(); let url = std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_logging tests"); sea_orm::Database::connect(&url).await.unwrap() } #[tokio::test] async fn log_blocked_login_dispatches_audit_event_when_hooks_provided() { let db = test_db().await; let sink: Arc<Mutex<Vec<(String, serde_json::Value)>>> = Arc::new(Mutex::new(Vec::new())); let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks { sink: sink.clone() }); AuthLoggingService::log_blocked_login( &db, "test-audit@example.com", "email_not_verified", Some(&hooks), ) .await .unwrap(); let events = sink.lock().unwrap().clone(); assert_eq!(events.len(), 1); assert_eq!(events[0].0, "auth.login_blocked"); assert_eq!(events[0].1["email"], "test-audit@example.com"); assert_eq!(events[0].1["reason"], "email_not_verified"); } #[tokio::test] async fn log_blocked_login_works_without_hooks() { let db = test_db().await; // None → no hook dispatch, no panic. AuthLoggingService::log_blocked_login( &db, "no-hook@example.com", "email_not_verified", None, ) .await .unwrap(); } }
GitRust