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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<p class="text-slate-400 text-sm mt-1">Review, search, and audit system activities and state changes</p>
|
||||
</div>
|
||||
<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">
|
||||
Manage Users
|
||||
</a>
|
||||
@@ -220,4 +223,38 @@
|
||||
<a href="/auth/password" class="font-medium text-sky-400 hover:underline">← Back to Account Settings</a>
|
||||
</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 %}
|
||||
|
||||
@@ -79,23 +79,6 @@
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user