Skip to main content

do_memory_mcp/server/audit/
core.rs

1//! Audit logging core implementation
2//!
3//! This module provides the core AuditLogger struct and its implementation,
4//! including file handling, rotation, and event logging.
5
6use super::types::{AuditConfig, AuditDestination, AuditLogEntry, AuditLogLevel};
7use chrono::Utc;
8use serde_json::json;
9use std::fs::{File, OpenOptions};
10use std::io::Write;
11use std::sync::Arc;
12use tokio::sync::Mutex;
13use tracing::{debug, error, info, warn};
14
15/// Audit logger implementation
16pub struct AuditLogger {
17    config: AuditConfig,
18    file_handle: Arc<Mutex<Option<File>>>,
19    current_file_size: Arc<Mutex<u64>>,
20}
21
22impl AuditLogger {
23    /// Create a new audit logger
24    pub async fn new(config: AuditConfig) -> anyhow::Result<Self> {
25        let file_handle = if config.destination == AuditDestination::File
26            || config.destination == AuditDestination::Both
27        {
28            let path = config
29                .file_path
30                .clone()
31                .unwrap_or_else(|| std::path::PathBuf::from("audit.log"));
32
33            // Ensure parent directory exists
34            if let Some(parent) = path.parent() {
35                tokio::fs::create_dir_all(parent).await?;
36            }
37
38            let file = OpenOptions::new().create(true).append(true).open(&path)?;
39
40            let metadata = file.metadata()?;
41            let current_size = metadata.len();
42
43            info!(
44                "Audit logger initialized with file: {:?} (current size: {} bytes)",
45                path, current_size
46            );
47
48            Some(file)
49        } else {
50            info!("Audit logger initialized with stdout output only");
51            None
52        };
53
54        Ok(Self {
55            config,
56            file_handle: Arc::new(Mutex::new(file_handle)),
57            current_file_size: Arc::new(Mutex::new(0)),
58        })
59    }
60
61    /// Log a generic audit event
62    pub async fn log_event(
63        &self,
64        level: AuditLogLevel,
65        client_id: &str,
66        operation: &str,
67        result: &str,
68        metadata: serde_json::Value,
69    ) {
70        if !self.config.enabled || !self.config.log_level.should_log(level) {
71            return;
72        }
73
74        let entry = AuditLogEntry {
75            timestamp: Utc::now().to_rfc3339(),
76            level: format!("{:?}", level).to_lowercase(),
77            client_id: client_id.to_string(),
78            operation: operation.to_string(),
79            result: result.to_string(),
80            metadata: self.redact_sensitive_data(metadata),
81        };
82
83        let log_line = match serde_json::to_string(&entry) {
84            Ok(line) => line,
85            Err(e) => {
86                error!("Failed to serialize audit log entry: {}", e);
87                return;
88            }
89        };
90
91        // Write to appropriate destinations
92        match self.config.destination {
93            AuditDestination::Stdout => {
94                println!("{}", log_line);
95            }
96            AuditDestination::File => {
97                self.write_to_file(&log_line).await;
98            }
99            AuditDestination::Both => {
100                println!("{}", log_line);
101                self.write_to_file(&log_line).await;
102            }
103        }
104
105        debug!("Audit log entry: {}", log_line);
106    }
107
108    /// Write log line to file with rotation support
109    fn write_to_file<'a>(
110        &'a self,
111        log_line: &'a str,
112    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>> {
113        Box::pin(async move {
114            let mut file_guard = self.file_handle.lock().await;
115            let mut size_guard = self.current_file_size.lock().await;
116
117            if let Some(ref mut file) = *file_guard {
118                // Check if rotation is needed
119                if self.config.enable_rotation && *size_guard >= self.config.max_file_size {
120                    drop(file_guard);
121                    drop(size_guard);
122                    self.rotate_logs().await;
123                    return self.write_to_file(log_line).await;
124                }
125
126                if let Err(e) = writeln!(file, "{}", log_line) {
127                    error!("Failed to write audit log to file: {}", e);
128                } else if let Err(e) = file.flush() {
129                    error!("Failed to flush audit log file: {}", e);
130                } else {
131                    *size_guard += log_line.len() as u64 + 1; // +1 for newline
132                }
133            }
134        })
135    }
136
137    /// Rotate log files
138    async fn rotate_logs(&self) {
139        if let Some(ref base_path) = self.config.file_path {
140            let base_path = base_path.clone();
141
142            // Close current file
143            {
144                let mut file_guard = self.file_handle.lock().await;
145                *file_guard = None;
146            }
147
148            // Rotate existing files
149            for i in (1..self.config.max_rotated_files).rev() {
150                let old_path = if i == 1 {
151                    base_path.clone()
152                } else {
153                    base_path.with_extension(format!("log.{}", i - 1))
154                };
155
156                let new_path = base_path.with_extension(format!("log.{}", i));
157
158                if old_path.exists() {
159                    if let Err(e) = tokio::fs::rename(&old_path, &new_path).await {
160                        warn!(
161                            "Failed to rotate log file {:?} to {:?}: {}",
162                            old_path, new_path, e
163                        );
164                    }
165                }
166            }
167
168            // Remove oldest file if it exists
169            let oldest_path =
170                base_path.with_extension(format!("log.{}", self.config.max_rotated_files));
171            if oldest_path.exists() {
172                if let Err(e) = tokio::fs::remove_file(&oldest_path).await {
173                    warn!("Failed to remove oldest log file {:?}: {}", oldest_path, e);
174                }
175            }
176
177            // Reopen file for writing
178            match OpenOptions::new()
179                .create(true)
180                .append(true)
181                .open(&base_path)
182            {
183                Ok(file) => {
184                    let mut file_guard = self.file_handle.lock().await;
185                    let mut size_guard = self.current_file_size.lock().await;
186                    *file_guard = Some(file);
187                    *size_guard = 0;
188                    info!("Log files rotated successfully");
189                }
190                Err(e) => {
191                    error!("Failed to reopen audit log file after rotation: {}", e);
192                }
193            }
194        }
195    }
196
197    /// Redact sensitive data from metadata
198    fn redact_sensitive_data(&self, mut metadata: serde_json::Value) -> serde_json::Value {
199        if let Some(obj) = metadata.as_object_mut() {
200            for (key, value) in obj.iter_mut() {
201                if self
202                    .config
203                    .redact_fields
204                    .iter()
205                    .any(|f| key.to_lowercase().contains(f))
206                {
207                    *value = json!("[REDACTED]");
208                }
209            }
210        }
211        metadata
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::super::types::{AuditConfig, AuditDestination, AuditLogLevel};
218    use super::*;
219    use std::collections::HashSet;
220    use tempfile::TempDir;
221
222    #[tokio::test]
223    async fn test_audit_logger_creation() {
224        let config = AuditConfig::default();
225        let logger = AuditLogger::new(config).await;
226        assert!(logger.is_ok());
227    }
228
229    #[tokio::test]
230    async fn test_audit_logger_with_file() {
231        let temp_dir = TempDir::new().unwrap();
232        let log_path = temp_dir.path().join("test_audit.log");
233
234        let config = AuditConfig {
235            enabled: true,
236            destination: AuditDestination::File,
237            file_path: Some(log_path.clone()),
238            enable_rotation: false,
239            max_file_size: 1024,
240            max_rotated_files: 5,
241            redact_fields: HashSet::new(),
242            log_level: AuditLogLevel::Debug,
243        };
244
245        let logger = AuditLogger::new(config).await.unwrap();
246
247        // Log an event
248        logger
249            .log_event(
250                AuditLogLevel::Info,
251                "test-client",
252                "test_operation",
253                "success",
254                json!({"test": "data"}),
255            )
256            .await;
257
258        // Give a moment for the file to be written
259        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
260
261        // Check that file was created and contains the log
262        assert!(log_path.exists());
263        let content = tokio::fs::read_to_string(&log_path).await.unwrap();
264        assert!(content.contains("test-client"));
265        assert!(content.contains("test_operation"));
266    }
267
268    #[test]
269    fn test_redact_sensitive_data() {
270        let mut config = AuditConfig::default();
271        config.redact_fields.insert("secret".to_string());
272
273        let logger = AuditLogger {
274            config: config.clone(),
275            file_handle: Arc::new(Mutex::new(None)),
276            current_file_size: Arc::new(Mutex::new(0)),
277        };
278
279        let metadata = json!({
280            "public_field": "visible",
281            "secret_key": "should_be_hidden",
282            "nested_secret": "also_hidden"
283        });
284
285        let redacted = logger.redact_sensitive_data(metadata);
286        let obj = redacted.as_object().unwrap();
287
288        assert_eq!(obj["public_field"], "visible");
289        assert_eq!(obj["secret_key"], "[REDACTED]");
290        assert_eq!(obj["nested_secret"], "[REDACTED]");
291    }
292
293    #[tokio::test]
294    async fn test_audit_logger_disabled() {
295        let config = AuditConfig {
296            enabled: false,
297            ..AuditConfig::default()
298        };
299
300        let logger = AuditLogger::new(config).await.unwrap();
301
302        // This should not panic or error when disabled
303        logger
304            .log_event(
305                AuditLogLevel::Info,
306                "test-client",
307                "test_operation",
308                "success",
309                json!({}),
310            )
311            .await;
312    }
313}