""" Audit Logging System Comprehensive audit logging for all MCP operations. Tracks tool calls, authentication attempts, and system events. Features: - Structured JSON logging - Log rotation support - Query and filter capabilities - Export to JSON/CSV - GDPR-compliant (no sensitive data in logs) """ import json import logging from datetime import UTC, datetime from enum import Enum from pathlib import Path from typing import Any class LogLevel(Enum): """Log severity levels.""" INFO = "INFO" WARNING = "WARNING" ERROR = "ERROR" CRITICAL = "CRITICAL" class EventType(Enum): """Types of events to log.""" TOOL_CALL = "tool_call" AUTHENTICATION = "authentication" HEALTH_CHECK = "health_check" ERROR = "error" SYSTEM = "system" class AuditLogger: """ Audit logging system for MCP operations. Logs all important events to a structured JSON log file with rotation support and query capabilities. """ def __init__( self, log_dir: str = "logs", log_file: str = "audit.log", max_file_size_mb: int = 10, backup_count: int = 5, ): """ Initialize audit logger. Args: log_dir: Directory for log files log_file: Log file name max_file_size_mb: Max size before rotation (MB) backup_count: Number of backup files to keep """ # Setup Python logger for internal logging self.logger = logging.getLogger("AuditLogger") # Try to create log directory, fallback to /tmp if permission denied self.log_dir = Path(log_dir) try: self.log_dir.mkdir(parents=True, exist_ok=True) except PermissionError: # Fallback to /tmp/logs for Docker containers self.logger.warning(f"Permission denied for {log_dir}, using /tmp/logs instead") self.log_dir = Path("/tmp/logs") self.log_dir.mkdir(parents=True, exist_ok=True) except Exception as e: self.logger.error(f"Failed to create log directory: {e}, using /tmp/logs") self.log_dir = Path("/tmp/logs") try: self.log_dir.mkdir(parents=True, exist_ok=True) except Exception as e2: self.logger.critical(f"Cannot create any log directory: {e2}, logging disabled") # Set to None to disable file logging self.log_dir = None if self.log_dir: self.log_file = self.log_dir / log_file self.max_file_size = max_file_size_mb * 1024 * 1024 # Convert to bytes self.backup_count = backup_count self.logger.info(f"Audit logger initialized: {self.log_file}") else: self.log_file = None self.max_file_size = 0 self.backup_count = 0 self.logger.warning("Audit logging to file is disabled due to permission errors") def _rotate_logs_if_needed(self) -> None: """Rotate log files if size exceeds limit.""" if not self.log_file or not self.log_file.exists(): return if self.log_file.stat().st_size >= self.max_file_size: # Rotate existing backup files for i in range(self.backup_count - 1, 0, -1): old_backup = self.log_dir / f"{self.log_file.name}.{i}" new_backup = self.log_dir / f"{self.log_file.name}.{i + 1}" if old_backup.exists(): if new_backup.exists(): new_backup.unlink() # Delete oldest if at limit old_backup.rename(new_backup) # Move current log to .1 backup = self.log_dir / f"{self.log_file.name}.1" if backup.exists(): backup.unlink() self.log_file.rename(backup) self.logger.info(f"Log rotated: {self.log_file}") def _write_log_entry(self, entry: dict[str, Any]) -> None: """ Write a log entry to file. Args: entry: Log entry dictionary """ # Skip if logging is disabled if not self.log_file: return self._rotate_logs_if_needed() try: with open(self.log_file, "a", encoding="utf-8") as f: # Write as JSON line json.dump(entry, f, ensure_ascii=False) f.write("\n") except Exception as e: self.logger.error(f"Failed to write audit log: {e}", exc_info=True) def log_tool_call( self, tool_name: str, site: str | None = None, project_id: str | None = None, params: dict[str, Any] | None = None, result_summary: str | None = None, error: str | None = None, duration_ms: int | None = None, user_id: str | None = None, ) -> None: """ Log a tool call. Args: tool_name: Name of the tool called site: Site ID or alias (for unified tools) project_id: Full project ID (for per-site tools) params: Tool parameters (sensitive data should be filtered) result_summary: Brief summary of result (not full response) error: Error message if failed duration_ms: Execution duration in milliseconds user_id: User identifier (if available) """ # Filter sensitive data from params safe_params = self._filter_sensitive_data(params) if params else None entry = { "timestamp": datetime.now(UTC).isoformat(), "event_type": EventType.TOOL_CALL.value, "level": LogLevel.ERROR.value if error else LogLevel.INFO.value, "tool_name": tool_name, "site": site, "project_id": project_id, "params": safe_params, "result_summary": result_summary, "error": error, "duration_ms": duration_ms, "user_id": user_id, "success": error is None, } self._write_log_entry(entry) def log_authentication( self, success: bool, project_id: str | None = None, reason: str | None = None, ip_address: str | None = None, ) -> None: """ Log an authentication attempt. Args: success: Whether authentication succeeded project_id: Project being accessed (if known) reason: Failure reason if unsuccessful ip_address: Client IP address """ entry = { "timestamp": datetime.now(UTC).isoformat(), "event_type": EventType.AUTHENTICATION.value, "level": LogLevel.WARNING.value if not success else LogLevel.INFO.value, "success": success, "project_id": project_id, "reason": reason, "ip_address": ip_address, } self._write_log_entry(entry) def log_error( self, error_type: str, error_message: str, context: dict[str, Any] | None = None, stack_trace: str | None = None, ) -> None: """ Log an error event. Args: error_type: Type of error (e.g., 'ValidationError', 'APIError') error_message: Error message context: Additional context stack_trace: Stack trace if available """ entry = { "timestamp": datetime.now(UTC).isoformat(), "event_type": EventType.ERROR.value, "level": LogLevel.ERROR.value, "error_type": error_type, "error_message": error_message, "context": context, "stack_trace": stack_trace, } self._write_log_entry(entry) def log_system_event( self, event: str, details: dict[str, Any] | None = None, level: LogLevel = LogLevel.INFO ) -> None: """ Log a system event. Args: event: Event description details: Event details level: Log level """ entry = { "timestamp": datetime.now(UTC).isoformat(), "event_type": EventType.SYSTEM.value, "level": level.value, "event": event, "details": details, } self._write_log_entry(entry) def _filter_sensitive_data(self, data: dict[str, Any]) -> dict[str, Any]: """ Filter sensitive data from logs (GDPR compliance). Args: data: Data to filter Returns: Filtered data with sensitive fields masked """ if not data: return {} sensitive_keys = { "password", "app_password", "token", "api_key", "secret", "credential", "auth", "private_key", "access_token", "refresh_token", } filtered = {} for key, value in data.items(): # Check if key contains sensitive words if any(sensitive in key.lower() for sensitive in sensitive_keys): filtered[key] = "[REDACTED]" elif isinstance(value, dict): filtered[key] = self._filter_sensitive_data(value) else: filtered[key] = value return filtered def get_logs( self, event_type: EventType | None = None, start_time: datetime | None = None, end_time: datetime | None = None, level: LogLevel | None = None, project_id: str | None = None, tool_name: str | None = None, success_only: bool | None = None, limit: int = 100, ) -> list[dict[str, Any]]: """ Query audit logs with filters. Args: event_type: Filter by event type start_time: Start of time range end_time: End of time range level: Filter by log level project_id: Filter by project tool_name: Filter by tool name success_only: Only successful operations limit: Maximum number of entries to return Returns: List of log entries matching filters """ if not self.log_file or not self.log_file.exists(): return [] results = [] try: with open(self.log_file, encoding="utf-8") as f: for line in f: if not line.strip(): continue try: entry = json.loads(line) # Apply filters if event_type and entry.get("event_type") != event_type.value: continue if level and entry.get("level") != level.value: continue if project_id and entry.get("project_id") != project_id: continue if tool_name and entry.get("tool_name") != tool_name: continue if success_only is not None: if entry.get("success") != success_only: continue # Time range filter if start_time or end_time: entry_time = datetime.fromisoformat(entry.get("timestamp", "")) if start_time and entry_time < start_time: continue if end_time and entry_time > end_time: continue results.append(entry) if len(results) >= limit: break except json.JSONDecodeError: self.logger.warning(f"Invalid JSON in log: {line[:50]}...") continue except Exception as e: self.logger.error(f"Error reading logs: {e}", exc_info=True) return results def export_logs(self, output_path: str, format: str = "json", **filter_kwargs) -> bool: """ Export logs to a file. Args: output_path: Output file path format: Export format ('json' or 'csv') **filter_kwargs: Filters to apply (same as get_logs) Returns: True if successful """ logs = self.get_logs(**filter_kwargs) try: if format == "json": with open(output_path, "w", encoding="utf-8") as f: json.dump(logs, f, indent=2, ensure_ascii=False) elif format == "csv": import csv if not logs: return False # Get all unique keys from logs keys = set() for log in logs: keys.update(log.keys()) with open(output_path, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=sorted(keys)) writer.writeheader() writer.writerows(logs) else: raise ValueError(f"Unsupported format: {format}") self.logger.info(f"Exported {len(logs)} logs to {output_path}") return True except Exception as e: self.logger.error(f"Error exporting logs: {e}", exc_info=True) return False def get_recent_entries(self, limit: int = 10) -> list[dict[str, Any]]: """ Get the most recent log entries. Args: limit: Maximum number of entries to return Returns: List of recent log entries (newest first) """ if not self.log_file or not self.log_file.exists(): return [] entries = [] try: with open(self.log_file, encoding="utf-8") as f: # Read all lines and get the last N lines = f.readlines() # Process lines in reverse order for line in reversed(lines): if not line.strip(): continue try: entry = json.loads(line) # Format the entry for display formatted_entry = { "timestamp": entry.get("timestamp", ""), "event_type": entry.get("event_type", "unknown"), "level": entry.get("level", "INFO"), "message": self._format_log_message(entry), "metadata": { "project_id": entry.get("project_id"), "tool_name": entry.get("tool_name"), "site": entry.get("site"), "duration_ms": entry.get("duration_ms"), "success": entry.get("success"), }, } entries.append(formatted_entry) if len(entries) >= limit: break except json.JSONDecodeError: continue except Exception as e: self.logger.error(f"Error reading recent logs: {e}", exc_info=True) return entries def _format_log_message(self, entry: dict[str, Any]) -> str: """Format a log entry into a human-readable message.""" event_type = entry.get("event_type", "") if event_type == EventType.TOOL_CALL.value: tool_name = entry.get("tool_name", "unknown") if entry.get("error"): return f"{tool_name} failed: {entry.get('error', '')[:50]}" return f"{tool_name}" elif event_type == EventType.AUTHENTICATION.value: if entry.get("success"): return "Authentication successful" return f"Authentication failed: {entry.get('reason', 'unknown')}" elif event_type == EventType.ERROR.value: return f"{entry.get('error_type', 'Error')}: {entry.get('error_message', '')[:50]}" elif event_type == EventType.SYSTEM.value: return entry.get("event", "System event") return entry.get("event", entry.get("message", "Unknown event")) def get_statistics(self) -> dict[str, Any]: """ Get statistics about audit logs. Returns: Dictionary with statistics """ all_logs = self.get_logs(limit=10000) # Get recent logs if not all_logs: return {"total_entries": 0, "by_type": {}, "by_level": {}, "success_rate": 0.0} # Count by type by_type = {} by_level = {} successful = 0 total_with_success_field = 0 for entry in all_logs: # Count by type event_type = entry.get("event_type", "unknown") by_type[event_type] = by_type.get(event_type, 0) + 1 # Count by level level = entry.get("level", "unknown") by_level[level] = by_level.get(level, 0) + 1 # Success rate if "success" in entry: total_with_success_field += 1 if entry["success"]: successful += 1 success_rate = ( (successful / total_with_success_field * 100) if total_with_success_field > 0 else 0.0 ) # Calculate log file size log_file_size_mb = 0 if self.log_file and self.log_file.exists(): log_file_size_mb = round(self.log_file.stat().st_size / (1024 * 1024), 2) return { "total_entries": len(all_logs), "by_type": by_type, "by_level": by_level, "success_rate": round(success_rate, 2), "log_file_size_mb": log_file_size_mb, } # Global audit logger instance _audit_logger: AuditLogger | None = None def get_audit_logger() -> AuditLogger: """Get the global audit logger instance.""" global _audit_logger if _audit_logger is None: _audit_logger = AuditLogger() return _audit_logger