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