do_memory_mcp/server/audit/
core.rs1use 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
15pub struct AuditLogger {
17 config: AuditConfig,
18 file_handle: Arc<Mutex<Option<File>>>,
19 current_file_size: Arc<Mutex<u64>>,
20}
21
22impl AuditLogger {
23 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 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 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 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 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 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; }
133 }
134 })
135 }
136
137 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 {
144 let mut file_guard = self.file_handle.lock().await;
145 *file_guard = None;
146 }
147
148 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 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 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 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 logger
249 .log_event(
250 AuditLogLevel::Info,
251 "test-client",
252 "test_operation",
253 "success",
254 json!({"test": "data"}),
255 )
256 .await;
257
258 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
260
261 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 logger
304 .log_event(
305 AuditLogLevel::Info,
306 "test-client",
307 "test_operation",
308 "success",
309 json!({}),
310 )
311 .await;
312 }
313}