feat: implement audit logs system, request extractor, admin log panel, and dedicated documentation

- Added an enterprise-grade, request-scoped AuditLogger extractor in Axum.
- Configured MongoDB persistence for structured, replayable audit logs (capturing timestamp, user, action, type, payload snapshot, client IP with proxy header support, and User-Agent).
- Created a live Administrator console at /auth/audit to filter and inspect log events.
- Re-architected documentation by moving Design Wiki pages out of /components into a dedicated /docs route.
- Published logging architecture documentation at /docs/logging.
This commit is contained in:
2026-05-30 18:23:49 +05:00
parent f6ea8a99d9
commit 4c98dd93ad
34 changed files with 1389 additions and 134 deletions
+71 -1
View File
@@ -4,6 +4,7 @@ use axum::{
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
};
use crate::audit::AuditLogger;
use mongodb::bson::oid::ObjectId;
use serde::Deserialize;
use crate::common::errors::AppError;
@@ -49,6 +50,7 @@ struct DashboardTemplate {
pub async fn get_dashboard(
State(task_repo): State<MongoTaskRepository>,
State(dev_repo): State<MongoDeveloperRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
) -> Result<Response, AppError> {
let Some(user) = user_opt else {
@@ -70,6 +72,14 @@ pub async fn get_dashboard(
});
}
audit.log(
"View",
"Task",
None,
Some("Viewed task dashboard".to_string()),
None
).await;
Ok(HtmlTemplate(DashboardTemplate {
username: user.username,
authenticated: true,
@@ -87,6 +97,7 @@ pub struct CreateTaskForm {
pub async fn post_create_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Form(payload): Form<CreateTaskForm>,
) -> Result<Response, AppError> {
@@ -108,15 +119,31 @@ pub async fn post_create_task(
_ => None,
};
task_repo
let new_task = task_repo
.create(&user.user_id, title, description, assigned_to)
.await?;
let payload_val = serde_json::json!({
"title": new_task.title,
"description": new_task.description,
"assigned_to": new_task.assigned_to.map(|id| id.to_hex()),
"is_completed": new_task.is_completed,
});
audit.log(
"Create",
"Task",
new_task.id,
Some(format!("Created task: {}", title)),
Some(payload_val)
).await;
Ok(Redirect::to("/tasks").into_response())
}
pub async fn post_complete_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(task_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -127,13 +154,37 @@ pub async fn post_complete_task(
let task_id = ObjectId::parse_str(&task_id_str)
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
let task_before = task_repo.find_by_id(&task_id, &user.user_id).await?;
task_repo.mark_completed(&task_id, &user.user_id).await?;
if let Some(tb) = task_before {
let payload_val = serde_json::json!({
"before": {
"title": tb.title,
"is_completed": tb.is_completed,
},
"after": {
"title": tb.title,
"is_completed": true,
}
});
audit.log(
"Update",
"Task",
Some(task_id),
Some(format!("Marked task '{}' as completed", tb.title)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/tasks").into_response())
}
pub async fn post_delete_task(
State(task_repo): State<MongoTaskRepository>,
audit: AuditLogger,
user_opt: Option<AuthenticatedUser>,
Path(task_id_str): Path<String>,
) -> Result<Response, AppError> {
@@ -144,7 +195,26 @@ pub async fn post_delete_task(
let task_id = ObjectId::parse_str(&task_id_str)
.map_err(|_| AppError::BadRequest("Invalid task identifier".to_string()))?;
let task_to_delete = task_repo.find_by_id(&task_id, &user.user_id).await?;
task_repo.delete(&task_id, &user.user_id).await?;
if let Some(td) = task_to_delete {
let payload_val = serde_json::json!({
"title": td.title,
"description": td.description,
"assigned_to": td.assigned_to.map(|id| id.to_hex()),
"is_completed": td.is_completed,
});
audit.log(
"Delete",
"Task",
Some(task_id),
Some(format!("Deleted task: {}", td.title)),
Some(payload_val)
).await;
}
Ok(Redirect::to("/tasks").into_response())
}
+1
View File
@@ -14,6 +14,7 @@ where
Config: axum::extract::FromRef<S>,
MongoTaskRepository: axum::extract::FromRef<S>,
crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef<S>,
mongodb::Database: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
+8
View File
@@ -10,6 +10,7 @@ use crate::tasks::models::Task;
#[async_trait::async_trait]
pub trait TaskRepository {
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError>;
async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Task>, AppError>;
async fn create(&self, user_id: &ObjectId, title: &str, description: Option<&str>, assigned_to: Option<ObjectId>) -> Result<Task, AppError>;
async fn mark_completed(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
async fn delete(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<(), AppError>;
@@ -28,6 +29,13 @@ impl MongoTaskRepository {
#[async_trait::async_trait]
impl TaskRepository for MongoTaskRepository {
async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result<Option<Task>, AppError> {
let collection = self.db.collection::<Task>("tasks");
let filter = doc! { "_id": task_id, "user_id": user_id };
let task = collection.find_one(filter).await?;
Ok(task)
}
async fn find_all_by_user(&self, user_id: &ObjectId) -> Result<Vec<Task>, AppError> {
let collection = self.db.collection::<Task>("tasks");