From bb35206fff66b19edb556dabad3dbb64a10bbf5a Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sat, 30 May 2026 18:41:59 +0500 Subject: [PATCH] feat: implement administrator-driven audit log purging with forced archival download - Created find_older_than and delete_older_than database repository methods. - Built a /auth/audit/purge POST endpoint that validates retention days (min 1 day). - Serializes and streams matching logs as an attachment JSON download for archival before database deletion. - Added a 'Purge Logs' button and modal interface in the administrator dashboard, automatically reloading the view after download. --- src/audit/handlers.rs | 70 ++++++++++++++++++++++++++++++++++++ src/audit/mod.rs | 4 ++- src/audit/repository.rs | 29 +++++++++++++++ templates/audit/list.html | 37 +++++++++++++++++++ templates/auth/password.html | 17 --------- 5 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/audit/handlers.rs b/src/audit/handlers.rs index 07c370a..c30191a 100644 --- a/src/audit/handlers.rs +++ b/src/audit/handlers.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Query, State}, response::{Html, IntoResponse, Response}, http::StatusCode, + Form, }; use serde::Deserialize; use crate::common::errors::AppError; @@ -73,3 +74,72 @@ pub async fn get_audit_logs( }) .into_response()) } + +#[derive(Deserialize, Debug)] +pub struct PurgeRequest { + pub retention_days: i64, +} + +pub async fn purge_audit_logs( + user: AuthenticatedUser, + State(audit_repo): State, + logger: crate::audit::AuditLogger, + Form(input): Form, +) -> Result { + if !user.is_admin { + return Err(AppError::Forbidden("Only administrators can purge audit logs".to_string())); + } + + if input.retention_days < 1 { + return Err(AppError::BadRequest("Retention window must be at least 1 day".to_string())); + } + + let threshold = chrono::Utc::now() - chrono::Duration::days(input.retention_days); + + // 1. Fetch logs that are going to be purged + let logs_to_purge = audit_repo.find_older_than(threshold).await?; + + if logs_to_purge.is_empty() { + return Err(AppError::BadRequest("No audit logs found older than the retention period".to_string())); + } + + // 2. Serialize logs to JSON + let json_data = serde_json::to_vec_pretty(&logs_to_purge) + .map_err(|e| AppError::Internal(format!("Failed to serialize logs: {}", e)))?; + + // 3. Delete from the database + let deleted_count = audit_repo.delete_older_than(threshold).await?; + + // 4. Log the purge action itself (which stays because it was created now, retention is >= 1 day) + logger.log( + "Purge", + "System", + None, + Some(format!( + "Purged {} logs older than {} days (Threshold: {})", + deleted_count, input.retention_days, threshold.format("%Y-%m-%d %H:%M:%S UTC") + )), + Some(serde_json::json!({ + "retention_days": input.retention_days, + "deleted_count": deleted_count, + "threshold": threshold, + })), + ).await; + + // 5. Construct the attachment response to force download + let filename = format!( + "audit_logs_purge_{}.json", + chrono::Utc::now().format("%Y%m%d_%H%M%S") + ); + + let response = Response::builder() + .header(axum::http::header::CONTENT_TYPE, "application/json") + .header( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ) + .body(axum::body::Body::from(json_data)) + .map_err(|e| AppError::Internal(format!("Failed to construct response: {}", e)))?; + + Ok(response) +} diff --git a/src/audit/mod.rs b/src/audit/mod.rs index bc1ffe5..ddf109e 100644 --- a/src/audit/mod.rs +++ b/src/audit/mod.rs @@ -2,7 +2,7 @@ pub mod models; pub mod repository; pub mod handlers; -use axum::{routing::get, Router, extract::FromRef}; +use axum::{routing::{get, post}, Router, extract::FromRef}; use crate::common::config::Config; use crate::audit::repository::MongoAuditRepository; use crate::auth::extractors::AuthenticatedUser; @@ -14,10 +14,12 @@ pub fn router() -> Router where Config: axum::extract::FromRef, MongoAuditRepository: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() .route("/auth/audit", get(handlers::get_audit_logs)) + .route("/auth/audit/purge", post(handlers::purge_audit_logs)) } /// Lower-level helper function to write audit log entries directly to the database. diff --git a/src/audit/repository.rs b/src/audit/repository.rs index 4568f2e..4cc4954 100644 --- a/src/audit/repository.rs +++ b/src/audit/repository.rs @@ -104,4 +104,33 @@ impl MongoAuditRepository { let logs = cursor.try_collect::>().await?; Ok(logs) } + + pub async fn find_older_than( + &self, + threshold: chrono::DateTime, + ) -> Result, AppError> { + use futures::TryStreamExt; + let collection = self.db.collection::("audit_logs"); + let filter = doc! { + "timestamp": { "$lt": mongodb::bson::DateTime::from_chrono(threshold) } + }; + let find_options = mongodb::options::FindOptions::builder() + .sort(doc! { "timestamp": 1 }) + .build(); + let cursor = collection.find(filter).with_options(find_options).await?; + let logs = cursor.try_collect::>().await?; + Ok(logs) + } + + pub async fn delete_older_than( + &self, + threshold: chrono::DateTime, + ) -> Result { + let collection = self.db.collection::("audit_logs"); + let filter = doc! { + "timestamp": { "$lt": mongodb::bson::DateTime::from_chrono(threshold) } + }; + let result = collection.delete_many(filter).await?; + Ok(result.deleted_count) + } } diff --git a/templates/audit/list.html b/templates/audit/list.html index 8fde308..abf8b21 100644 --- a/templates/audit/list.html +++ b/templates/audit/list.html @@ -13,6 +13,9 @@

Review, search, and audit system activities and state changes

+ Manage Users @@ -220,4 +223,38 @@ ← Back to Account Settings
+ + + {% endblock %} diff --git a/templates/auth/password.html b/templates/auth/password.html index 226cb2e..1f8c0fc 100644 --- a/templates/auth/password.html +++ b/templates/auth/password.html @@ -79,23 +79,6 @@ - -
-
- - - -
-
-

Developer Roster

-

Manage team developer profiles, update their technical skills, or clear their metadata.

- -
-