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}, extract::{Query, State},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
http::StatusCode, http::StatusCode,
Form,
}; };
use serde::Deserialize; use serde::Deserialize;
use crate::common::errors::AppError; use crate::common::errors::AppError;
@@ -73,3 +74,72 @@ pub async fn get_audit_logs(
}) })
.into_response()) .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 repository;
pub mod handlers; 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::common::config::Config;
use crate::audit::repository::MongoAuditRepository; use crate::audit::repository::MongoAuditRepository;
use crate::auth::extractors::AuthenticatedUser; use crate::auth::extractors::AuthenticatedUser;
@@ -14,10 +14,12 @@ pub fn router<S>() -> Router<S>
where where
Config: axum::extract::FromRef<S>, Config: axum::extract::FromRef<S>,
MongoAuditRepository: axum::extract::FromRef<S>, MongoAuditRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static, S: Clone + Send + Sync + 'static,
{ {
Router::new() Router::new()
.route("/auth/audit", get(handlers::get_audit_logs)) .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. /// 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?; let logs = cursor.try_collect::<Vec<AuditLog>>().await?;
Ok(logs) 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)
}
} }
+37
View File
@@ -13,6 +13,9 @@
<p class="text-slate-400 text-sm mt-1">Review, search, and audit system activities and state changes</p> <p class="text-slate-400 text-sm mt-1">Review, search, and audit system activities and state changes</p>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button data-modal-target="purge-modal" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-rose-950/40 border border-rose-900/30 hover:bg-rose-950/60 text-rose-400 shadow-md shadow-rose-950/10">
Purge Logs
</button>
<a href="/auth/users" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300"> <a href="/auth/users" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-slate-900 border border-slate-800 hover:bg-slate-800 text-slate-300">
Manage Users Manage Users
</a> </a>
@@ -220,4 +223,38 @@
<a href="/auth/password" class="font-medium text-sky-400 hover:underline">← Back to Account Settings</a> <a href="/auth/password" class="font-medium text-sky-400 hover:underline">← Back to Account Settings</a>
</div> </div>
</div> </div>
<!-- Purge Modal Dialog -->
<div id="purge-modal" class="modal-dialog fixed inset-0 z-50 flex items-center justify-center hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop fixed inset-0 bg-[#07090e]/80 backdrop-blur-sm transition-opacity duration-300"></div>
<div class="modal-content relative z-10 w-full max-w-md scale-95 opacity-0 transition-all duration-300 border border-border bg-popover/95 backdrop-blur-xl p-6 shadow-2xl rounded-3xl">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/10 border border-rose-500/20 text-rose-400 mb-3">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</div>
<h3 class="text-sm font-bold text-slate-100 text-center">Purge Audit Logs</h3>
<p class="text-xs text-slate-400 mt-2 text-center leading-relaxed">
Specify the number of days of logs to retain. This operation will permanently delete all logs older than this window. You will be forced to download a JSON archival dump of the purged logs before deletion.
</p>
<form method="POST" action="/auth/audit/purge" class="mt-4 space-y-4">
<div>
<label for="retention_days" class="block text-[11px] font-semibold text-slate-400 mb-1.5 text-left">Retention Period (Days)</label>
<input type="number" id="retention_days" name="retention_days" min="1" value="30" required
class="block h-10 w-full rounded-xl border border-border bg-background px-4 py-2 text-sm text-white placeholder-slate-650 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/50">
<span class="text-[10px] text-slate-500 mt-1 block text-left">Minimum 1 day of logs must be retained.</span>
</div>
<div class="flex gap-3 pt-2">
<button type="button" class="modal-close flex-1 py-2.5 rounded-xl bg-secondary border border-border hover:bg-slate-800 transition text-xs font-semibold text-slate-200">
Cancel
</button>
<button type="submit" class="flex-1 py-2.5 rounded-xl bg-rose-650 hover:bg-rose-600 text-white shadow-lg shadow-rose-600/10 transition text-xs font-bold" onclick="setTimeout(() => { window.closeModal(document.getElementById('purge-modal')); window.location.reload(); }, 1500)">
Archived Purge
</button>
</div>
</form>
</div>
</div>
{% endblock %} {% endblock %}
-17
View File
@@ -79,23 +79,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="p-5 rounded-2xl bg-indigo-500/5 border border-indigo-500/10 flex items-start gap-4">
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.109A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
</div>
<div>
<h4 class="text-sm font-bold text-slate-200">Developer Roster</h4>
<p class="text-xs text-slate-400 mt-1 leading-relaxed">Manage team developer profiles, update their technical skills, or clear their metadata.</p>
<div class="mt-4">
<a href="/developers" class="inline-flex items-center justify-center rounded-xl text-xs font-bold transition-all px-4 py-2.5 bg-secondary border border-border hover:bg-secondary/80 text-slate-200">
Manage Developers
</a>
</div>
</div>
</div>
</div> </div>
</div> </div>