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.
This commit is contained in:
2026-05-30 18:41:59 +05:00
parent 4c98dd93ad
commit bb35206fff
5 changed files with 139 additions and 18 deletions
+70
View File
@@ -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<MongoAuditRepository>,
logger: crate::audit::AuditLogger,
Form(input): Form<PurgeRequest>,
) -> Result<Response, AppError> {
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)
}
+3 -1
View File
@@ -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<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoAuditRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
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.
+29
View File
@@ -104,4 +104,33 @@ impl MongoAuditRepository {
let logs = cursor.try_collect::<Vec<AuditLog>>().await?;
Ok(logs)
}
pub async fn find_older_than(
&self,
threshold: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<AuditLog>, AppError> {
use futures::TryStreamExt;
let collection = self.db.collection::<AuditLog>("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::<Vec<AuditLog>>().await?;
Ok(logs)
}
pub async fn delete_older_than(
&self,
threshold: chrono::DateTime<chrono::Utc>,
) -> Result<u64, AppError> {
let collection = self.db.collection::<AuditLog>("audit_logs");
let filter = doc! {
"timestamp": { "$lt": mongodb::bson::DateTime::from_chrono(threshold) }
};
let result = collection.delete_many(filter).await?;
Ok(result.deleted_count)
}
}