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
+75
View File
@@ -0,0 +1,75 @@
use askama::Template;
use axum::{
extract::{Query, State},
response::{Html, IntoResponse, Response},
http::StatusCode,
};
use serde::Deserialize;
use crate::common::errors::AppError;
use crate::auth::extractors::AuthenticatedUser;
use crate::audit::repository::MongoAuditRepository;
use crate::audit::models::AuditLog;
// Wrapper for rendering Askama HTML
struct HtmlTemplate<T>(T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => {
tracing::error!("Failed to render template: {:?}", err);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
#[derive(Deserialize, Clone, Debug)]
pub struct AuditQuery {
pub username: Option<String>,
pub action_type: Option<String>,
pub entity_type: Option<String>,
pub entity_id: Option<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Template)]
#[template(path = "audit/list.html")]
pub struct AuditListTemplate {
pub authenticated: bool,
pub username: String,
pub logs: Vec<AuditLog>,
pub query: AuditQuery,
}
pub async fn get_audit_logs(
user: AuthenticatedUser,
State(audit_repo): State<MongoAuditRepository>,
Query(query): Query<AuditQuery>,
) -> Result<Response, AppError> {
if !user.is_admin {
return Err(AppError::Forbidden("Only administrators can access audit logs".to_string()));
}
let logs = audit_repo.find_filtered(
query.username.as_deref(),
query.action_type.as_deref(),
query.entity_type.as_deref(),
query.entity_id.as_deref(),
query.start_date.as_deref(),
query.end_date.as_deref(),
).await?;
Ok(HtmlTemplate(AuditListTemplate {
authenticated: true,
username: user.username,
logs,
query,
})
.into_response())
}
+165
View File
@@ -0,0 +1,165 @@
pub mod models;
pub mod repository;
pub mod handlers;
use axum::{routing::get, Router, extract::FromRef};
use crate::common::config::Config;
use crate::audit::repository::MongoAuditRepository;
use crate::auth::extractors::AuthenticatedUser;
use crate::audit::models::AuditLog;
use axum::extract::ConnectInfo;
use std::net::SocketAddr;
pub fn router<S>() -> Router<S>
where
Config: axum::extract::FromRef<S>,
MongoAuditRepository: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/audit", get(handlers::get_audit_logs))
}
/// Lower-level helper function to write audit log entries directly to the database.
pub async fn log_action(
db: &mongodb::Database,
user_opt: Option<&AuthenticatedUser>,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
ip_address: Option<String>,
user_agent: Option<String>,
) {
let log = AuditLog {
id: None,
timestamp: chrono::Utc::now(),
user_id: user_opt.map(|u| u.user_id),
username: user_opt.map(|u| u.username.clone()),
action_type: action_type.to_string(),
entity_type: entity_type.to_string(),
entity_id,
details,
payload,
ip_address,
user_agent,
};
let repo = MongoAuditRepository::new(db.clone());
if let Err(e) = repo.insert(log).await {
tracing::error!("Failed to write audit log: {:?}", e);
}
}
/// A request-scoped extractor that automatically resolves request metadata (IP, User-Agent),
/// retrieves the database connection, and captures the optional authenticated user.
/// This pattern simplifies audit trail logging inside handler functions to a single line.
pub struct AuditLogger {
pub db: mongodb::Database,
pub user: Option<AuthenticatedUser>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
impl<S> axum::extract::FromRequestParts<S> for AuditLogger
where
mongodb::Database: axum::extract::FromRef<S>,
Config: axum::extract::FromRef<S>,
S: Send + Sync,
{
type Rejection = crate::common::errors::AppError;
async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> {
let db = mongodb::Database::from_ref(state);
// Try to authenticate the user for this request. If it fails, user is None.
let user = AuthenticatedUser::from_request_parts(parts, state).await.ok();
let user_agent = parts.headers.get(axum::http::header::USER_AGENT)
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let socket_ip = parts.extensions.get::<ConnectInfo<SocketAddr>>()
.map(|ConnectInfo(addr)| addr.ip().to_string());
let proxy_ip = parts.headers.get("x-forwarded-for")
.and_then(|h| h.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.or_else(|| {
parts.headers.get("x-real-ip")
.and_then(|h| h.to_str().ok())
.map(|s| s.trim().to_string())
});
let ip_address = match (proxy_ip, socket_ip) {
(Some(p_ip), Some(s_ip)) => {
if p_ip == s_ip {
Some(p_ip)
} else {
Some(format!("{} (Socket: {})", p_ip, s_ip))
}
}
(Some(p_ip), None) => Some(p_ip),
(None, Some(s_ip)) => Some(s_ip),
(None, None) => None,
};
Ok(AuditLogger {
db,
user,
ip_address,
user_agent,
})
}
}
impl AuditLogger {
/// Logs an action for the currently authenticated user resolved by this request.
pub async fn log(
&self,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
) {
log_action(
&self.db,
self.user.as_ref(),
action_type,
entity_type,
entity_id,
details,
payload,
self.ip_address.clone(),
self.user_agent.clone(),
).await;
}
/// Logs an action with an explicitly provided user (e.g., during login sequences
/// before the user token cookie is set, or for login failures).
pub async fn log_with_user(
&self,
user: Option<&AuthenticatedUser>,
action_type: &str,
entity_type: &str,
entity_id: Option<mongodb::bson::oid::ObjectId>,
details: Option<String>,
payload: Option<serde_json::Value>,
) {
log_action(
&self.db,
user,
action_type,
entity_type,
entity_id,
details,
payload,
self.ip_address.clone(),
self.user_agent.clone(),
).await;
}
}
+35
View File
@@ -0,0 +1,35 @@
use mongodb::bson::oid::ObjectId;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AuditLog {
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
pub id: Option<ObjectId>,
#[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")]
pub timestamp: chrono::DateTime<chrono::Utc>,
pub user_id: Option<ObjectId>,
pub username: Option<String>,
pub action_type: String, // e.g., Login, Logout, Create, Update, Delete, View, Search
pub entity_type: String, // e.g., User, Task, Developer, System
pub entity_id: Option<ObjectId>,
pub details: Option<String>,
pub payload: Option<serde_json::Value>, // State snapshot / diff for replayability
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
impl AuditLog {
pub fn formatted_payload(&self) -> String {
match &self.payload {
Some(val) => serde_json::to_string_pretty(val).unwrap_or_else(|_| "".to_string()),
None => "".to_string(),
}
}
}
+107
View File
@@ -0,0 +1,107 @@
use mongodb::{
bson::{doc, oid::ObjectId},
Database,
};
use crate::common::errors::AppError;
use crate::audit::models::AuditLog;
#[derive(Clone)]
pub struct MongoAuditRepository {
db: Database,
}
impl MongoAuditRepository {
pub fn new(db: Database) -> Self {
Self { db }
}
pub async fn insert(&self, log: AuditLog) -> Result<(), AppError> {
let collection = self.db.collection::<AuditLog>("audit_logs");
collection.insert_one(log).await?;
Ok(())
}
pub async fn find_filtered(
&self,
username: Option<&str>,
action_type: Option<&str>,
entity_type: Option<&str>,
entity_id: Option<&str>,
start_date: Option<&str>,
end_date: Option<&str>,
) -> Result<Vec<AuditLog>, AppError> {
use futures::TryStreamExt;
let collection = self.db.collection::<AuditLog>("audit_logs");
let mut filter = doc! {};
if let Some(uname) = username {
let trimmed = uname.trim();
if !trimmed.is_empty() {
filter.insert("username", doc! { "$regex": trimmed, "$options": "i" });
}
}
if let Some(act) = action_type {
let trimmed = act.trim();
if !trimmed.is_empty() && trimmed != "all" {
filter.insert("action_type", trimmed);
}
}
if let Some(ent) = entity_type {
let trimmed = ent.trim();
if !trimmed.is_empty() && trimmed != "all" {
filter.insert("entity_type", trimmed);
}
}
if let Some(ent_id_str) = entity_id {
let trimmed = ent_id_str.trim();
if !trimmed.is_empty() {
if let Ok(oid) = ObjectId::parse_str(trimmed) {
filter.insert("entity_id", oid);
}
}
}
// Date filters range
let mut date_query = doc! {};
if let Some(start) = start_date {
let trimmed = start.trim();
if !trimmed.is_empty() {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
if let Some(naive_datetime) = naive_date.and_hms_opt(0, 0, 0) {
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
date_query.insert("$gte", mongodb::bson::DateTime::from_chrono(dt));
}
}
}
}
if let Some(end) = end_date {
let trimmed = end.trim();
if !trimmed.is_empty() {
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
if let Some(naive_datetime) = naive_date.and_hms_opt(23, 59, 59) {
let dt = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive_datetime, chrono::Utc);
date_query.insert("$lte", mongodb::bson::DateTime::from_chrono(dt));
}
}
}
}
if !date_query.is_empty() {
filter.insert("timestamp", date_query);
}
let find_options = mongodb::options::FindOptions::builder()
.sort(doc! { "timestamp": -1 })
.limit(1000)
.build();
let cursor = collection.find(filter).with_options(find_options).await?;
let logs = cursor.try_collect::<Vec<AuditLog>>().await?;
Ok(logs)
}
}