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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user