From 4c98dd93adaaacaba1ecc664bcbb1f5c850e2512 Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sat, 30 May 2026 18:23:49 +0500 Subject: [PATCH] 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. --- README.md | 157 +++++++++--- src/audit/handlers.rs | 75 ++++++ src/audit/mod.rs | 165 ++++++++++++ src/audit/models.rs | 35 +++ src/audit/repository.rs | 107 ++++++++ src/auth/handlers.rs | 132 +++++++++- src/auth/mod.rs | 1 + src/components/mod.rs | 30 --- src/developers/handlers.rs | 97 ++++++- src/developers/mod.rs | 1 + src/{components => docs}/handlers.rs | 38 ++- src/docs/mod.rs | 32 +++ src/main.rs | 18 +- src/tasks/handlers.rs | 72 +++++- src/tasks/mod.rs | 1 + src/tasks/repository.rs | 8 + templates/audit/list.html | 223 ++++++++++++++++ templates/auth/users.html | 3 + templates/base.html | 4 +- templates/components/sidebar.html | 27 -- templates/{components => docs}/buttons.html | 2 +- templates/{components => docs}/combobox.html | 2 +- templates/{components => docs}/date_time.html | 2 +- templates/{components => docs}/feedback.html | 2 +- templates/{components => docs}/index.html | 4 +- templates/{components => docs}/inputs.html | 2 +- templates/docs/logging.html | 240 ++++++++++++++++++ templates/{components => docs}/modals.html | 2 +- .../{components => docs}/scrollbars.html | 2 +- templates/{components => docs}/sheets.html | 2 +- templates/docs/sidebar.html | 31 +++ .../{components => docs}/tabs_accordion.html | 2 +- templates/{components => docs}/toggles.html | 2 +- templates/{components => docs}/visuals.html | 2 +- 34 files changed, 1389 insertions(+), 134 deletions(-) create mode 100644 src/audit/handlers.rs create mode 100644 src/audit/mod.rs create mode 100644 src/audit/models.rs create mode 100644 src/audit/repository.rs delete mode 100644 src/components/mod.rs rename src/{components => docs}/handlers.rs (86%) create mode 100644 src/docs/mod.rs create mode 100644 templates/audit/list.html delete mode 100644 templates/components/sidebar.html rename templates/{components => docs}/buttons.html (99%) rename templates/{components => docs}/combobox.html (99%) rename templates/{components => docs}/date_time.html (99%) rename templates/{components => docs}/feedback.html (99%) rename templates/{components => docs}/index.html (95%) rename templates/{components => docs}/inputs.html (99%) create mode 100644 templates/docs/logging.html rename templates/{components => docs}/modals.html (99%) rename templates/{components => docs}/scrollbars.html (99%) rename templates/{components => docs}/sheets.html (99%) create mode 100644 templates/docs/sidebar.html rename templates/{components => docs}/tabs_accordion.html (99%) rename templates/{components => docs}/toggles.html (99%) rename templates/{components => docs}/visuals.html (99%) diff --git a/README.md b/README.md index 5c3f3ba..214d8cd 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,90 @@ # Stick -Stick is a Rust web application starter template built on Axum, Askama, and MongoDB. Unlike traditional MVC architectures that organize code by technical layers (controllers, models, views), Stick is organized by **vertical slices** (features or use-cases). All files related to a specific domain feature—such as authentication or task management—live together in a single module. +Stick is a high-fidelity Rust web application starter template built on Axum, Askama, and MongoDB. -This setup is ideal for medium-to-large projects where horizontal layers become hard to navigate, and compiling templates at runtime is too risky. +Unlike traditional MVC frameworks that segment code horizontally by technical layers (controllers, models, views), Stick is organized by **vertical slices**. Each domain feature—such as authentication, tasks, or audit logging—lives together in its own self-contained module. This simplifies code maintenance and cognitive load in medium-to-large codebases where horizontal layers inevitably grow difficult to navigate. --- -## Technical Architecture - -### 1. Vertical Slice Layout -Each feature slice is self-contained. For example, the authentication domain has the following structure: +## Architectural Philosophy & Layout +### 1. Vertical Slice Domain Layout +Every domain feature owns its database access models, business logic, endpoints, and markup templates. For example: * `src/auth/models.rs`: BSON data structures and claims. * `src/auth/repository.rs`: Database operations and query logic. * `src/auth/handlers.rs`: Request/response lifecycle logic. -* `src/auth/extractors.rs`: Axum extractors for session state. -* `templates/auth/`: HTML markup templates rendered at compile-time. +* `src/auth/extractors.rs`: Axum extractors for user session handling. +* `templates/auth/`: HTML templates compiled at build-time. -### 2. Key Stack Decisions -* **Axum (v0.8)**: Handles routing, middleware, and request extraction. -* **Askama (v0.16)**: Evaluates and compiles HTML templates into Rust code at compile time. If you reference a variable or field that doesn't exist, the project fails to compile, catching UI rendering bugs before deployment. +### 2. Separating Components from Documentation +To prevent domain pollution and maintain code integrity, we strictly separate template assets: +* **Reusable UI Components** (`templates/components/`): Reserved strictly for operational UI blocks used across the application. Reusable elements like fields, dropdowns, calendars, and buttons are defined as Askama macros inside [templates/components/macros.html](templates/components/macros.html). +* **System Documentation** (`templates/docs/` & `src/docs/`): Design manuals, guidelines, and interactive preview sandboxes are isolated. The Documentation portal is served on a dedicated `/docs` route, separate from core application logic. + +### 3. Key Stack Decisions +* **Axum (v0.8)**: Modern, async-first routing and middleware. +* **Askama (v0.16)**: Evaluates and compiles HTML templates into Rust code at compile time. Syntax, variable existence, and type constraints are verified by the compiler. * **MongoDB**: Standard Rust driver configured with BSON serialization. -* **Tailwind CSS**: Pre-compiled utility styling using a Node-based wrapper process. -* **Authentication**: Managed via JWT (JSON Web Tokens) stored in secure, encrypted `HttpOnly` cookies. +* **Tailwind CSS**: Utility-first styling compiled using a Node-based wrapper process. +* **JWT Authentication**: Managed via JSON Web Tokens stored in secure, encrypted `HttpOnly` cookies. + +--- + +## Audit Logging Framework (First-Class Feature) + +Stick contains a built-in, request-scoped **Audit Logging and Replay** framework designed to make all critical system mutations (Create, Read, Update, Delete, Search) auditable. + +``` + Request + │ + ▼ +┌──────────────────┐ +│ AuditLogger │ ──► Resolves: DB, User, User-Agent +│ Extractor │ +└──────────────────┘ + │ + ├──► Parses "x-forwarded-for" & "x-real-ip" proxy headers + ├──► Parses Direct ConnectInfo TCP socket IP + │ + ▼ +┌──────────────────┐ +│ Log Entry Writ │ ──► Formats: "ProxyIP (Socket: SocketIP)" if mismatched +└──────────────────┘ + │ + ▼ +┌──────────────────┐ +│ MongoDB Store │ ──► Stores structured entry + JSON state snapshot +└──────────────────┘ +``` + +### 1. Request-Scoped Extractor (`AuditLogger`) +The custom `AuditLogger` extractor implements Axum's `FromRequestParts` trait. When added to any handler function signature, it automatically resolves: +* Database connection reference. +* Optional authenticated user context. +* Client metadata (IP address and User-Agent). + +### 2. Intelligent IP & Proxy Resolution +To support load-balanced or proxied environments, the extractor resolves and logs both network paths: +- Inspects standard proxy headers (`x-forwarded-for`, `x-real-ip`). +- Captures the direct TCP client connection address via Axum's `ConnectInfo`. +- If the proxy IP and connecting socket IP differ, the logger combines them (e.g. `10.0.0.1 (Socket: 127.0.0.1)`) ensuring full visibility of both the request origin and proxy hop. + +### 3. Historical State Snapshotting (Replayability) +Log entries include a serialized JSON `payload` field containing the state of the affected entity. For edits, it saves the state transition. For deletions, it saves the final snapshot before removal, ensuring data remains reconstructible in the event of an audit inquiry. + +### 4. Admin Audit Panel +Authorized administrators can review, search, and audit system activities at `/auth/audit`. The dashboard allows filtering entries by: +* **Username**: Filter logs to a specific actor. +* **Event Type**: Filter by actions (e.g. `Login`, `Create`, `Update`, `Delete`). +* **Entity Type & ID**: Pinpoint history for a specific resource (e.g. `Task`, `Developer`). +* **Timeline**: Filter logs by start and end timestamps. --- ## Core Features Included ### Self-Provisioning Administrator -On startup, the application checks if the `users` collection in the MongoDB database is empty. If no users are found, it generates a secure, random 16-character alphanumeric password, hashes it with bcrypt, and creates a default `admin` account. The credentials are logged directly to standard output: - +On startup, if the MongoDB `users` collection is empty, Stick automatically provisions an administrator account with username `admin`, generates a secure random 16-character alphanumeric password, and outputs the credentials directly to the console logs: ```text ====================================================== CREATED INITIAL ADMINISTRATOR ACCOUNT: @@ -39,20 +93,11 @@ Password: [GeneratedPassword] ====================================================== ``` -### Decoupled Identity vs. Domain Entities -Stick strictly separates infrastructure/user models from business domain models: -* **Users** (`User`): Manage authentication, roles (`is_admin`), and settings under `/auth`. -* **Developers** (`Developer`): Plain domain entities managed under `/developers` that represent team members. +### User Management Panel (Admin Only) +Accessible at `/auth/users`. Administrators can view all users, register new ones, toggle administrative roles, reset passwords, or delete standard accounts (with safety blocks preventing administrators from deleting their own active profile or revoking their own privileges). -### User Management Panel (Administrators Only) -Accessible under `/auth/users` by logged-in administrators. The panel allows: -* Viewing all registered users and their administrative permissions. -* Editing user profiles, resetting passwords, and toggling administrator roles. -* Deleting users (with safeguards preventing administrators from deleting their own active accounts or revoking their own admin permissions). -* Registering new users. - -### Self-Service Account Settings -Any authenticated user can change their own password by clicking their username in the navigation bar, which directs them to `/auth/password`. +### Self-Service Password Reset +Any logged-in user can change their password at `/auth/password` by clicking their username in the navigation bar. --- @@ -61,7 +106,7 @@ Any authenticated user can change their own password by clicking their username ### Prerequisites * **Rust**: Toolchain v1.75+ (for native async traits). * **Node.js & npm**: Required to build Tailwind CSS. -* **MongoDB**: Running locally on `mongodb://localhost:27017`. +* **MongoDB**: Running locally on `mongodb://127.0.0.1:27017`. ### Local Setup 1. Copy the environment configuration: @@ -77,16 +122,16 @@ Any authenticated user can change their own password by clicking their username ```bash cargo run ``` - The server will start listening at `http://127.0.0.1:3000`. + The server will start listening at `http://127.0.0.1:3009` (as configured in `.env`). ### Running with Docker -A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust binary, and bundle a lightweight Debian run container. +A multi-stage `Dockerfile` compiles Tailwind, compiles the Rust binary, and bundle a lightweight Debian runtime container. 1. Build the image: ```bash docker build -t stick . ``` -2. Start the container (assumes MongoDB is running on the host machine): +2. Start the container: ```bash docker run --name stick-app --rm --network="host" \ -e DATABASE_URL="mongodb://127.0.0.1:27017" \ @@ -99,31 +144,59 @@ A multi-stage `Dockerfile` is provided to compile Tailwind, compile the Rust bin --- -## Developer Guide: Adding a Feature Slice - -To add a new feature (e.g. `projects`): +## Developer Cookbook +### 1. Adding a Feature Slice (e.g. `projects`) 1. Create a module folder: `src/projects/`. -2. Define models in `models.rs` and database access functions in `repository.rs`. +2. Define models in `models.rs` and database access operations in `repository.rs`. 3. Add request handlers in `handlers.rs`. -4. Create a router configuration in `src/projects/mod.rs` exposing a routing module setup: +4. Create `src/projects/mod.rs` to expose the router: ```rust pub fn router() -> Router where - Config: axum::extract::FromRef, - MongoUserRepository: axum::extract::FromRef, + crate::common::config::Config: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() .route("/projects", get(handlers::get_projects)) } ``` -5. Place HTML layouts under `templates/projects/` extending the `base.html` layout. -6. Register and merge the router in `src/main.rs`: +5. Place HTML templates under `templates/projects/` extending the `base.html` layout. +6. Register the module in `src/main.rs` and merge the sub-router: ```rust let app = Router::new() .merge(main_view::router()) .merge(auth::router()) - .merge(projects::router()) // Merged domain router + .merge(projects::router()) // Merge domain router .with_state(state); ``` + +### 2. Logging Operations with `AuditLogger` +To log any action in a handler, inject `logger: AuditLogger` and invoke `.log()` after a successful DB mutation: + +```rust +use crate::audit::AuditLogger; +use serde_json::json; + +pub async fn delete_task_handler( + State(repo): State, + logger: AuditLogger, // <-- Automatically injected + Path(task_id): Path, +) -> Result { + // 1. Fetch task and perform mutation + let task = repo.get_by_id(&task_id).await?; + repo.delete(&task_id).await?; + + // 2. Audit the event (single line, fully context-aware) + logger.log( + "Delete", // Event action + "Task", // Entity type + Some(task_id), // Target entity ID + Some(format!("Deleted task '{}'", task.title)), // Description + Some(json!(task)), // Serialized payload for replayability + ).await; + + Ok(Redirect::to("/tasks")) +} +``` diff --git a/src/audit/handlers.rs b/src/audit/handlers.rs new file mode 100644 index 0000000..07c370a --- /dev/null +++ b/src/audit/handlers.rs @@ -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); + +impl IntoResponse for HtmlTemplate +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, + pub action_type: Option, + pub entity_type: Option, + pub entity_id: Option, + pub start_date: Option, + pub end_date: Option, +} + +#[derive(Template)] +#[template(path = "audit/list.html")] +pub struct AuditListTemplate { + pub authenticated: bool, + pub username: String, + pub logs: Vec, + pub query: AuditQuery, +} + +pub async fn get_audit_logs( + user: AuthenticatedUser, + State(audit_repo): State, + Query(query): Query, +) -> Result { + 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()) +} diff --git a/src/audit/mod.rs b/src/audit/mod.rs new file mode 100644 index 0000000..bc1ffe5 --- /dev/null +++ b/src/audit/mod.rs @@ -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() -> Router +where + Config: axum::extract::FromRef, + MongoAuditRepository: axum::extract::FromRef, + 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, + details: Option, + payload: Option, + ip_address: Option, + user_agent: Option, +) { + 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, + pub ip_address: Option, + pub user_agent: Option, +} + +impl axum::extract::FromRequestParts for AuditLogger +where + mongodb::Database: axum::extract::FromRef, + Config: axum::extract::FromRef, + S: Send + Sync, +{ + type Rejection = crate::common::errors::AppError; + + async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result { + 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::>() + .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, + details: Option, + payload: Option, + ) { + 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, + details: Option, + payload: Option, + ) { + log_action( + &self.db, + user, + action_type, + entity_type, + entity_id, + details, + payload, + self.ip_address.clone(), + self.user_agent.clone(), + ).await; + } +} diff --git a/src/audit/models.rs b/src/audit/models.rs new file mode 100644 index 0000000..4fe7d87 --- /dev/null +++ b/src/audit/models.rs @@ -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, + + #[serde_as(as = "mongodb::bson::serde_helpers::datetime::FromChrono04DateTime")] + pub timestamp: chrono::DateTime, + + pub user_id: Option, + pub username: Option, + 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, + pub details: Option, + + pub payload: Option, // State snapshot / diff for replayability + + pub ip_address: Option, + pub user_agent: Option, +} + +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(), + } + } +} diff --git a/src/audit/repository.rs b/src/audit/repository.rs new file mode 100644 index 0000000..4568f2e --- /dev/null +++ b/src/audit/repository.rs @@ -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::("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, AppError> { + use futures::TryStreamExt; + let collection = self.db.collection::("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::::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::::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::>().await?; + Ok(logs) + } +} diff --git a/src/auth/handlers.rs b/src/auth/handlers.rs index 2d48a9e..51c4adc 100644 --- a/src/auth/handlers.rs +++ b/src/auth/handlers.rs @@ -4,6 +4,7 @@ use axum::{ http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, }; +use crate::audit::AuditLogger; use axum_extra::extract::{ cookie::{Cookie, SameSite}, CookieJar, @@ -79,6 +80,7 @@ pub struct AuthPayload { pub async fn post_login( State(config): State, State(user_repo): State, + audit: AuditLogger, jar: CookieJar, Form(payload): Form, ) -> Result { @@ -87,6 +89,14 @@ pub async fn post_login( // Find user let user = user_repo.find_by_username(username).await?; let Some(user) = user else { + audit.log_with_user( + None, + "Login", + "User", + None, + Some(format!("Failed login attempt (unknown username): {}", username)), + None + ).await; return Ok(HtmlTemplate(LoginTemplate { error: Some("Invalid username or password".to_string()), authenticated: false, @@ -99,6 +109,14 @@ pub async fn post_login( match verify(&payload.password, &user.password_hash) { Ok(true) => {} _ => { + audit.log_with_user( + None, + "Login", + "User", + user.id, + Some(format!("Failed login attempt (incorrect password) for user: {}", username)), + None + ).await; return Ok(HtmlTemplate(LoginTemplate { error: Some("Invalid username or password".to_string()), authenticated: false, @@ -116,7 +134,7 @@ pub async fn post_login( let claims = Claims { sub: user.id.expect("User document must have ID").to_hex(), - username: user.username, + username: user.username.clone(), is_admin: user.is_admin, exp, }; @@ -127,6 +145,21 @@ pub async fn post_login( &EncodingKey::from_secret(config.jwt_secret.as_bytes()), )?; + // Log success + let session_user = AuthenticatedUser { + user_id: user.id.unwrap(), + username: user.username.clone(), + is_admin: user.is_admin, + }; + audit.log_with_user( + Some(&session_user), + "Login", + "User", + user.id, + Some(format!("Successful login for user: {}", username)), + None + ).await; + // Set cookie let cookie = Cookie::build(("token", token)) .path("/") @@ -164,6 +197,7 @@ pub struct RegisterPayload { pub async fn post_register( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, Form(payload): Form, ) -> Result { if !user.is_admin { @@ -200,7 +234,20 @@ pub async fn post_register( // Create user let is_admin_val = payload.is_admin.is_some(); - user_repo.create(username, &hashed_password, is_admin_val).await?; + let new_user = user_repo.create(username, &hashed_password, is_admin_val).await?; + + let payload_val = serde_json::json!({ + "username": new_user.username, + "is_admin": new_user.is_admin, + }); + + audit.log( + "Create", + "User", + new_user.id, + Some(format!("Registered new user: {}", username)), + Some(payload_val) + ).await; Ok(HtmlTemplate(RegisterTemplate { error: None, @@ -211,7 +258,21 @@ pub async fn post_register( .into_response()) } -pub async fn post_logout(jar: CookieJar) -> impl IntoResponse { +pub async fn post_logout( + user_opt: Option, + audit: AuditLogger, + jar: CookieJar, +) -> impl IntoResponse { + if let Some(user) = &user_opt { + audit.log( + "Logout", + "User", + Some(user.user_id), + Some(format!("Logged out user: {}", user.username)), + None + ).await; + } + let mut cookie = Cookie::new("token", ""); cookie.set_path("/"); cookie.set_max_age(Some(Duration::ZERO)); // Clear cookie @@ -252,6 +313,7 @@ pub async fn get_password( pub async fn post_password( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, Form(payload): Form, ) -> Result { let current_password = payload.current_password.trim(); @@ -307,6 +369,14 @@ pub async fn post_password( // Update password in database user_repo.update_password(&user.user_id, &hashed_password).await?; + audit.log( + "Update", + "User", + Some(user.user_id), + Some(format!("Self-updated password for user: {}", user.username)), + None + ).await; + Ok(HtmlTemplate(PasswordTemplate { error: None, success: Some("Password updated successfully!".to_string()), @@ -340,6 +410,7 @@ pub struct UserEditTemplate { pub async fn get_users( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, ) -> Result { if !user.is_admin { return Err(AppError::Forbidden("Only administrators can access user management".to_string())); @@ -347,6 +418,14 @@ pub async fn get_users( let users = user_repo.find_all().await?; + audit.log( + "View", + "User", + None, + Some("Viewed list of all registered users".to_string()), + None + ).await; + Ok(HtmlTemplate(UserListTemplate { authenticated: true, username: user.username, @@ -358,6 +437,7 @@ pub async fn get_users( pub async fn get_edit_user( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, Path(id_str): Path, ) -> Result { if !user.is_admin { @@ -370,6 +450,14 @@ pub async fn get_edit_user( let user_to_edit = user_repo.find_by_id(&oid).await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + audit.log( + "View", + "User", + Some(oid), + Some(format!("Viewed edit page for user: {}", user_to_edit.username)), + None + ).await; + Ok(HtmlTemplate(UserEditTemplate { error: None, success: None, @@ -390,6 +478,7 @@ pub struct EditUserPayload { pub async fn post_edit_user( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, Path(id_str): Path, Form(payload): Form, ) -> Result { @@ -456,6 +545,26 @@ pub async fn post_edit_user( let updated_user = user_repo.find_by_id(&oid).await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let payload_val = serde_json::json!({ + "before": { + "username": user_to_edit.username, + "is_admin": user_to_edit.is_admin, + }, + "after": { + "username": updated_user.username, + "is_admin": updated_user.is_admin, + "password_reset": password_hash_opt.is_some(), + } + }); + + audit.log( + "Update", + "User", + Some(oid), + Some(format!("Updated details for user: {}", new_username)), + Some(payload_val) + ).await; + Ok(HtmlTemplate(UserEditTemplate { error: None, success: Some("User updated successfully!".to_string()), @@ -469,6 +578,7 @@ pub async fn post_edit_user( pub async fn post_delete_user( user: AuthenticatedUser, State(user_repo): State, + audit: AuditLogger, Path(id_str): Path, ) -> Result { if !user.is_admin { @@ -483,7 +593,23 @@ pub async fn post_delete_user( return Err(AppError::BadRequest("You cannot delete your own account while logged in".to_string())); } + let user_to_delete = user_repo.find_by_id(&oid).await? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + user_repo.delete(&oid).await?; + + let payload_val = serde_json::json!({ + "username": user_to_delete.username, + "is_admin": user_to_delete.is_admin, + }); + + audit.log( + "Delete", + "User", + Some(oid), + Some(format!("Deleted user account: {}", user_to_delete.username)), + Some(payload_val) + ).await; Ok(Redirect::to("/auth/users").into_response()) } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index f4fda62..5b1efe3 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -14,6 +14,7 @@ pub fn router() -> Router where Config: axum::extract::FromRef, MongoUserRepository: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() diff --git a/src/components/mod.rs b/src/components/mod.rs deleted file mode 100644 index 992a0fd..0000000 --- a/src/components/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -pub mod handlers; - -use axum::{routing::get, Router}; -use self::handlers::{ - index_handler, buttons_handler, inputs_handler, date_time_handler, - combobox_handler, toggles_handler, modals_handler, sheets_handler, - tabs_accordion_handler, visuals_handler, scrollbars_handler, feedback_handler, -}; - -pub fn router() -> Router -where - crate::common::config::Config: axum::extract::FromRef, - mongodb::Database: axum::extract::FromRef, - crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef, - S: Clone + Send + Sync + 'static, -{ - Router::new() - .route("/components", get(index_handler)) - .route("/components/buttons", get(buttons_handler)) - .route("/components/inputs", get(inputs_handler)) - .route("/components/date-time", get(date_time_handler)) - .route("/components/combobox", get(combobox_handler)) - .route("/components/toggles", get(toggles_handler)) - .route("/components/modals", get(modals_handler)) - .route("/components/sheets", get(sheets_handler)) - .route("/components/tabs-accordion", get(tabs_accordion_handler)) - .route("/components/visuals", get(visuals_handler)) - .route("/components/scrollbars", get(scrollbars_handler)) - .route("/components/feedback", get(feedback_handler)) -} diff --git a/src/developers/handlers.rs b/src/developers/handlers.rs index 43b1004..7f79ab7 100644 --- a/src/developers/handlers.rs +++ b/src/developers/handlers.rs @@ -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; @@ -56,6 +57,7 @@ struct DeveloperSearchResultsTemplate { pub async fn get_list( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, ) -> Result { let Some(user) = user_opt else { @@ -65,6 +67,14 @@ pub async fn get_list( let _ = dev_repo.ensure_seeded(&user.user_id).await; let developers = dev_repo.find_all_by_user(&user.user_id).await?; + audit.log( + "View", + "Developer", + None, + Some("Viewed list of all developers".to_string()), + None + ).await; + Ok(HtmlTemplate(DeveloperListTemplate { username: user.username, authenticated: true, @@ -82,6 +92,7 @@ pub struct CreateDevForm { pub async fn post_create( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, Form(payload): Form, ) -> Result { @@ -101,13 +112,28 @@ pub async fn post_create( .filter(|s| !s.is_empty()) .collect(); - dev_repo.create(&user.user_id, name, email, skills).await?; + let new_dev = dev_repo.create(&user.user_id, name, email, skills).await?; + + let payload_val = serde_json::json!({ + "name": new_dev.name, + "email": new_dev.email, + "skills": new_dev.skills, + }); + + audit.log( + "Create", + "Developer", + new_dev.id, + Some(format!("Created developer: {}", name)), + Some(payload_val) + ).await; Ok(Redirect::to("/developers").into_response()) } pub async fn get_edit( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, Path(dev_id_str): Path, ) -> Result { @@ -123,6 +149,14 @@ pub async fn get_edit( return Err(AppError::Unauthorized("Developer not found".to_string())); }; + audit.log( + "View", + "Developer", + Some(dev_id), + Some(format!("Viewed edit page for developer: {}", developer.name)), + None + ).await; + Ok(HtmlTemplate(DeveloperEditTemplate { username: user.username, authenticated: true, @@ -133,6 +167,7 @@ pub async fn get_edit( pub async fn post_update( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, Path(dev_id_str): Path, Form(payload): Form, @@ -156,13 +191,41 @@ pub async fn post_update( .filter(|s| !s.is_empty()) .collect(); + let dev_before = dev_repo.find_by_id(&dev_id, &user.user_id).await?; + dev_repo.update(&dev_id, &user.user_id, name, email, skills).await?; + let dev_after = dev_repo.find_by_id(&dev_id, &user.user_id).await?; + + if let (Some(dbef), Some(daft)) = (dev_before, dev_after) { + let payload_val = serde_json::json!({ + "before": { + "name": dbef.name, + "email": dbef.email, + "skills": dbef.skills, + }, + "after": { + "name": daft.name, + "email": daft.email, + "skills": daft.skills, + } + }); + + audit.log( + "Update", + "Developer", + Some(dev_id), + Some(format!("Updated developer details for: {}", name)), + Some(payload_val) + ).await; + } + Ok(Redirect::to("/developers").into_response()) } pub async fn post_delete( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, Path(dev_id_str): Path, ) -> Result { @@ -173,8 +236,26 @@ pub async fn post_delete( let dev_id = ObjectId::parse_str(&dev_id_str) .map_err(|_| AppError::BadRequest("Invalid developer identifier".to_string()))?; + let dev_to_delete = dev_repo.find_by_id(&dev_id, &user.user_id).await?; + dev_repo.delete(&dev_id, &user.user_id).await?; + if let Some(dd) = dev_to_delete { + let payload_val = serde_json::json!({ + "name": dd.name, + "email": dd.email, + "skills": dd.skills, + }); + + audit.log( + "Delete", + "Developer", + Some(dev_id), + Some(format!("Deleted developer: {}", dd.name)), + Some(payload_val) + ).await; + } + Ok(Redirect::to("/developers").into_response()) } @@ -185,6 +266,7 @@ pub struct SearchQuery { pub async fn get_search( State(dev_repo): State, + audit: AuditLogger, user_opt: Option, Query(params): Query, ) -> Result { @@ -200,6 +282,19 @@ pub async fn get_search( let matched_devs = dev_repo.search_by_name(&user.user_id, query_str).await?; + let payload_val = serde_json::json!({ + "query": query_str, + "results_count": matched_devs.len(), + }); + + audit.log( + "Search", + "Developer", + None, + Some(format!("Searched developers with query: '{}'", query_str)), + Some(payload_val) + ).await; + Ok(HtmlTemplate(DeveloperSearchResultsTemplate { developers: matched_devs, }) diff --git a/src/developers/mod.rs b/src/developers/mod.rs index c54863e..5484679 100644 --- a/src/developers/mod.rs +++ b/src/developers/mod.rs @@ -13,6 +13,7 @@ pub fn router() -> Router where Config: axum::extract::FromRef, MongoDeveloperRepository: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() diff --git a/src/components/handlers.rs b/src/docs/handlers.rs similarity index 86% rename from src/components/handlers.rs rename to src/docs/handlers.rs index 3b00923..45fda96 100644 --- a/src/components/handlers.rs +++ b/src/docs/handlers.rs @@ -31,89 +31,96 @@ fn get_session_info(user_opt: Option) -> (bool, String) { // Define individual templates #[derive(Template)] -#[template(path = "components/index.html")] +#[template(path = "docs/index.html")] pub struct IndexTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/buttons.html")] +#[template(path = "docs/buttons.html")] pub struct ButtonsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/inputs.html")] +#[template(path = "docs/inputs.html")] pub struct InputsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/date_time.html")] +#[template(path = "docs/date_time.html")] pub struct DateTimeTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/combobox.html")] +#[template(path = "docs/combobox.html")] pub struct ComboboxTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/toggles.html")] +#[template(path = "docs/toggles.html")] pub struct TogglesTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/modals.html")] +#[template(path = "docs/modals.html")] pub struct ModalsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/sheets.html")] +#[template(path = "docs/sheets.html")] pub struct SheetsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/tabs_accordion.html")] +#[template(path = "docs/tabs_accordion.html")] pub struct TabsAccordionTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/visuals.html")] +#[template(path = "docs/visuals.html")] pub struct VisualsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/scrollbars.html")] +#[template(path = "docs/scrollbars.html")] pub struct ScrollbarsTemplate { pub username: String, pub authenticated: bool, } #[derive(Template)] -#[template(path = "components/feedback.html")] +#[template(path = "docs/feedback.html")] pub struct FeedbackTemplate { pub username: String, pub authenticated: bool, } +#[derive(Template)] +#[template(path = "docs/logging.html")] +pub struct LoggingTemplate { + pub username: String, + pub authenticated: bool, +} + // Define individual handlers pub async fn index_handler( user_opt: Option, @@ -207,3 +214,10 @@ pub async fn feedback_handler( let (authenticated, username) = get_session_info(user_opt); Ok(HtmlTemplate(FeedbackTemplate { username, authenticated })) } + +pub async fn logging_handler( + user_opt: Option, +) -> Result { + let (authenticated, username) = get_session_info(user_opt); + Ok(HtmlTemplate(LoggingTemplate { username, authenticated })) +} diff --git a/src/docs/mod.rs b/src/docs/mod.rs new file mode 100644 index 0000000..1ac463c --- /dev/null +++ b/src/docs/mod.rs @@ -0,0 +1,32 @@ +pub mod handlers; + +use axum::{routing::get, Router}; +use self::handlers::{ + index_handler, buttons_handler, inputs_handler, date_time_handler, + combobox_handler, toggles_handler, modals_handler, sheets_handler, + tabs_accordion_handler, visuals_handler, scrollbars_handler, feedback_handler, + logging_handler, +}; + +pub fn router() -> Router +where + crate::common::config::Config: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, + crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, +{ + Router::new() + .route("/docs", get(index_handler)) + .route("/docs/buttons", get(buttons_handler)) + .route("/docs/inputs", get(inputs_handler)) + .route("/docs/date-time", get(date_time_handler)) + .route("/docs/combobox", get(combobox_handler)) + .route("/docs/toggles", get(toggles_handler)) + .route("/docs/modals", get(modals_handler)) + .route("/docs/sheets", get(sheets_handler)) + .route("/docs/tabs-accordion", get(tabs_accordion_handler)) + .route("/docs/visuals", get(visuals_handler)) + .route("/docs/scrollbars", get(scrollbars_handler)) + .route("/docs/feedback", get(feedback_handler)) + .route("/docs/logging", get(logging_handler)) +} diff --git a/src/main.rs b/src/main.rs index 2f94dbd..194bf86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ mod common; mod auth; mod tasks; +mod audit; mod developers; mod main_view; -mod components; +mod docs; use axum::{extract::FromRef, Router}; use std::net::SocketAddr; @@ -15,6 +16,7 @@ use crate::common::database::connect_db; use crate::auth::repository::MongoUserRepository; use crate::tasks::repository::MongoTaskRepository; use crate::developers::repository::MongoDeveloperRepository; +use crate::audit::repository::MongoAuditRepository; #[derive(Clone)] struct AppState { @@ -23,6 +25,7 @@ struct AppState { user_repo: MongoUserRepository, task_repo: MongoTaskRepository, dev_repo: MongoDeveloperRepository, + audit_repo: MongoAuditRepository, } impl FromRef for Config { @@ -55,6 +58,12 @@ impl FromRef for MongoDeveloperRepository { } } +impl FromRef for MongoAuditRepository { + fn from_ref(state: &AppState) -> Self { + state.audit_repo.clone() + } +} + #[tokio::main] async fn main() -> Result<(), Box> { // 1. Initialize logging @@ -74,6 +83,7 @@ async fn main() -> Result<(), Box> { let user_repo = MongoUserRepository::new(db.clone()); let task_repo = MongoTaskRepository::new(db.clone()); let dev_repo = MongoDeveloperRepository::new(db.clone()); + let audit_repo = MongoAuditRepository::new(db.clone()); // Auto-provision initial administrator if users collection is empty let users_count = db.collection::("users") @@ -118,15 +128,17 @@ async fn main() -> Result<(), Box> { user_repo, task_repo, dev_repo, + audit_repo, }; // 6. Build and merge routers by use-case let app = Router::new() .merge(main_view::router()) - .merge(components::router()) + .merge(docs::router()) .merge(auth::router()) .merge(tasks::router()) .merge(developers::router()) + .merge(audit::router()) .with_state(state); // 7. Bind address and run server @@ -137,7 +149,7 @@ async fn main() -> Result<(), Box> { info!("Listening on http://{}", host_addr); let listener = tokio::net::TcpListener::bind(host_addr).await?; - axum::serve(listener, app).await?; + axum::serve(listener, app.into_make_service_with_connect_info::()).await?; Ok(()) } diff --git a/src/tasks/handlers.rs b/src/tasks/handlers.rs index e49039e..3be57c8 100644 --- a/src/tasks/handlers.rs +++ b/src/tasks/handlers.rs @@ -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, State(dev_repo): State, + audit: AuditLogger, user_opt: Option, ) -> Result { 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, + audit: AuditLogger, user_opt: Option, Form(payload): Form, ) -> Result { @@ -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, + audit: AuditLogger, user_opt: Option, Path(task_id_str): Path, ) -> Result { @@ -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, + audit: AuditLogger, user_opt: Option, Path(task_id_str): Path, ) -> Result { @@ -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()) } diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs index f7a6128..4b6a579 100644 --- a/src/tasks/mod.rs +++ b/src/tasks/mod.rs @@ -14,6 +14,7 @@ where Config: axum::extract::FromRef, MongoTaskRepository: axum::extract::FromRef, crate::developers::repository::MongoDeveloperRepository: axum::extract::FromRef, + mongodb::Database: axum::extract::FromRef, S: Clone + Send + Sync + 'static, { Router::new() diff --git a/src/tasks/repository.rs b/src/tasks/repository.rs index 3e63cd5..34b4b58 100644 --- a/src/tasks/repository.rs +++ b/src/tasks/repository.rs @@ -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, AppError>; + async fn find_by_id(&self, task_id: &ObjectId, user_id: &ObjectId) -> Result, AppError>; async fn create(&self, user_id: &ObjectId, title: &str, description: Option<&str>, assigned_to: Option) -> Result; 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, AppError> { + let collection = self.db.collection::("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, AppError> { let collection = self.db.collection::("tasks"); diff --git a/templates/audit/list.html b/templates/audit/list.html new file mode 100644 index 0000000..8fde308 --- /dev/null +++ b/templates/audit/list.html @@ -0,0 +1,223 @@ +{% extends "base.html" %} +{% import "components/macros.html" as ui %} + +{% block title %}Audit Logs - Stick{% endblock %} + +{% block content %} +
+ + +
+
+

Audit Logs

+

Review, search, and audit system activities and state changes

+
+
+ + Manage Users + + + Matches Found: {{ logs.len() }} + +
+
+ + +
+

Filter Log Entries

+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + Clear Filters + + +
+
+
+ + +
+
+ + + + + + + + + + + + + {% if logs.is_empty() %} + + + + {% else %} + {% for log in logs %} + + + + + + + + + + + + + + + + + + + + {% endfor %} + {% endif %} + +
TimestampUserEventTarget EntityIP / DetailsReplay Payload
+ No audit log entries matched the filter criteria. +
+ {{ log.timestamp.format("%Y-%m-%d %H:%M:%S UTC") }} + + {% if let Some(uname) = log.username %} + {{ uname }} + {% if let Some(uid) = log.user_id %} +
{{ uid.to_hex() }}
+ {% endif %} + {% else %} + Anonymous + {% endif %} +
+ + {{ log.action_type }} + + + {{ log.entity_type }} + {% if let Some(e_id) = log.entity_id %} +
+ {{ e_id.to_hex() }} +
+ {% endif %} +
+
+ {% if let Some(det) = log.details %} + {{ det }} + {% endif %} +
+
+ {% if let Some(ip) = log.ip_address %} + IP: {{ ip }} + {% endif %} + {% if let Some(ua) = log.user_agent %} + {{ ua }} + {% endif %} +
+
+ {% if !log.formatted_payload().is_empty() %} +
+ + View Payload + + + + +
{{ log.formatted_payload() }}
+
+ {% else %} + No payload + {% endif %} +
+
+
+ + + +
+{% endblock %} diff --git a/templates/auth/users.html b/templates/auth/users.html index c565e30..a612154 100644 --- a/templates/auth/users.html +++ b/templates/auth/users.html @@ -13,6 +13,9 @@

Manage system access, toggle roles, and provision credentials

+ + View Audit Logs + Register New User diff --git a/templates/base.html b/templates/base.html index 89103f0..ff72c1e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -49,8 +49,8 @@