| """ |
| Audit Logger for Device, Media, and Smart Home Actions |
| |
| This module provides comprehensive audit logging for all sensitive operations |
| in Personal Edition. Tracks media control (Spotify, Sonos), smart home |
| (Hue, Home Assistant), and creative (FFmpeg) actions with structured JSON logs. |
| |
| Features: |
| - Structured JSON logging with timestamp, user_id, agent_id, action, service |
| - Separate audit log file: logs/audit.log (rotates daily) |
| - Action categories: media, smarthome, creative, local_only_block |
| - Query methods for retrieving audit logs by user or service |
| - Log rotation and retention (90 days default, gzip compressed) |
| - Async logging to avoid blocking operations |
| - IP address tracking for network security |
| |
| Log Format: |
| { |
| "timestamp": "2026-02-20T10:30:00Z", |
| "user_id": "user_123", |
| "agent_id": "agent_456", |
| "action": "pause_playback", |
| "category": "media", |
| "service": "spotify", |
| "details": {"device_id": "device_abc"}, |
| "result": "success", |
| "ip_address": "192.168.1.100" |
| } |
| |
| Configuration: |
| - Environment: AUDIT_LOG_PATH (default: logs/audit.log) |
| - Environment: AUDIT_LOG_RETENTION_DAYS (default: 90) |
| |
| Usage: |
| from core.privsec.audit_logger import AuditLogger |
| |
| audit = AuditLogger() |
| audit.log_media_action( |
| user_id="user_123", |
| agent_id="agent_456", |
| action="pause_playback", |
| service="spotify", |
| details={"device_id": "device_abc"}, |
| result="success" |
| ) |
| """ |
|
|
| import asyncio |
| import gzip |
| import logging |
| import os |
| import json |
| from datetime import datetime, timedelta |
| from pathlib import Path |
| from typing import Any, Dict, List, Optional |
|
|
| from core.structured_logger import get_logger |
|
|
| logger = get_logger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| AUDIT_LOG_PATH = os.getenv("AUDIT_LOG_PATH", "logs/audit.log") |
| AUDIT_LOG_RETENTION_DAYS = int(os.getenv("AUDIT_LOG_RETENTION_DAYS", "90")) |
|
|
|
|
| |
| |
| |
|
|
| class AuditLogger: |
| """ |
| Singleton service for structured audit logging. |
| |
| Provides methods for logging media, smart home, and creative actions. |
| Writes to separate audit log file with JSON formatting. |
| Implements log rotation and retention policies. |
| |
| Thread-safe: Uses module-level singleton instance |
| """ |
|
|
| _instance: Optional['AuditLogger'] = None |
|
|
| def __new__(cls) -> 'AuditLogger': |
| """Implement singleton pattern.""" |
| if cls._instance is None: |
| cls._instance = super().__new__(cls) |
| return cls._instance |
|
|
| def __init__(self): |
| """Initialize audit logger (only runs once due to singleton).""" |
| if hasattr(self, '_initialized'): |
| return |
|
|
| self._initialized = True |
| self._log_path = Path(AUDIT_LOG_PATH) |
| self._retention_days = AUDIT_LOG_RETENTION_DAYS |
|
|
| |
| self._log_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| |
| self._setup_file_handler() |
|
|
| logger.info( |
| "AuditLogger initialized", |
| extra={ |
| "log_path": str(self._log_path), |
| "retention_days": self._retention_days |
| } |
| ) |
|
|
| def _setup_file_handler(self): |
| """Setup separate file handler for audit logs.""" |
| self._audit_handler = logging.FileHandler(self._log_path) |
| self._audit_handler.setFormatter( |
| logging.Formatter('%(message)s') |
| ) |
|
|
| |
| self._audit_logger = logging.getLogger('atom.audit') |
| self._audit_logger.addHandler(self._audit_handler) |
| self._audit_logger.setLevel(logging.INFO) |
| self._audit_logger.propagate = False |
|
|
| def _write_audit_log( |
| self, |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| category: str, |
| service: str, |
| details: Dict[str, Any], |
| result: str, |
| ip_address: Optional[str] = None |
| ): |
| """ |
| Write audit log entry (internal method). |
| |
| Args: |
| user_id: User ID performing action |
| agent_id: Agent ID (None if human action) |
| action: Action performed (e.g., "pause_playback") |
| category: Action category (media, smarthome, creative, local_only_block) |
| service: Service name (spotify, sonos, hue, etc.) |
| details: Additional details (device_id, track_uri, etc.) |
| result: Result (success, failed, blocked, error) |
| ip_address: Client IP address (optional) |
| """ |
| log_entry = { |
| "timestamp": datetime.utcnow().isoformat() + "Z", |
| "user_id": user_id, |
| "agent_id": agent_id, |
| "action": action, |
| "category": category, |
| "service": service, |
| "details": details, |
| "result": result, |
| "ip_address": ip_address |
| } |
|
|
| |
| self._audit_logger.info(json.dumps(log_entry)) |
|
|
| |
| |
| |
|
|
| def log_media_action( |
| self, |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| service: str, |
| details: Dict[str, Any], |
| result: str |
| ): |
| """ |
| Log media control action (Spotify, Sonos). |
| |
| Args: |
| user_id: User ID |
| agent_id: Agent ID (None if human) |
| action: Action (play, pause, skip, volume, etc.) |
| service: Service name (spotify, sonos) |
| details: Additional details (track_uri, device_id, speaker_ip) |
| result: Result (success, failed, blocked) |
| """ |
| self._write_audit_log( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=action, |
| category="media", |
| service=service, |
| details=details, |
| result=result |
| ) |
|
|
| |
| |
| |
|
|
| def log_smarthome_action( |
| self, |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| service: str, |
| details: Dict[str, Any], |
| result: str |
| ): |
| """ |
| Log smart home action (Hue, Home Assistant). |
| |
| Args: |
| user_id: User ID |
| agent_id: Agent ID (None if human) |
| action: Action (turn_on, turn_off, set_color, etc.) |
| service: Service name (hue, home_assistant) |
| details: Additional details (light_id, entity_id, bridge_ip) |
| result: Result (success, failed, blocked) |
| """ |
| self._write_audit_log( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=action, |
| category="smarthome", |
| service=service, |
| details=details, |
| result=result |
| ) |
|
|
| |
| |
| |
|
|
| def log_creative_action( |
| self, |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| operation: str, |
| details: Dict[str, Any], |
| result: str |
| ): |
| """ |
| Log creative tool action (FFmpeg). |
| |
| Args: |
| user_id: User ID |
| agent_id: Agent ID (None if human) |
| action: Action (trim_video, extract_audio, etc.) |
| operation: FFmpeg operation |
| details: Additional details (input_path, output_path, job_id) |
| result: Result (success, failed, blocked) |
| """ |
| self._write_audit_log( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=action, |
| category="creative", |
| service="ffmpeg", |
| details={**details, "operation": operation}, |
| result=result |
| ) |
|
|
| |
| |
| |
|
|
| def log_local_only_block( |
| self, |
| user_id: str, |
| agent_id: Optional[str], |
| service: str, |
| attempted_action: str, |
| reason: Optional[str] = None |
| ): |
| """ |
| Log when local-only mode blocks external service request. |
| |
| Args: |
| user_id: User ID |
| agent_id: Agent ID (None if human) |
| service: Service being accessed (spotify, notion, etc.) |
| attempted_action: Action being attempted |
| reason: Reason for blocking (optional) |
| """ |
| details = { |
| "attempted_action": attempted_action |
| } |
| if reason: |
| details["reason"] = reason |
|
|
| self._write_audit_log( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=f"blocked_{attempted_action}", |
| category="local_only_block", |
| service=service, |
| details=details, |
| result="blocked" |
| ) |
|
|
| |
| |
| |
|
|
| def get_user_audit_log(self, user_id: str, limit: int = 100) -> List[Dict]: |
| """ |
| Retrieve audit entries for specific user. |
| |
| Args: |
| user_id: User ID to query |
| limit: Maximum number of entries (default: 100) |
| |
| Returns: |
| List of audit log entries (most recent first) |
| """ |
| entries = [] |
|
|
| try: |
| with open(self._log_path, 'r') as f: |
| for line in f: |
| try: |
| entry = json.loads(line.strip()) |
| if entry.get('user_id') == user_id: |
| entries.append(entry) |
| if len(entries) >= limit: |
| break |
| except json.JSONDecodeError: |
| continue |
|
|
| except FileNotFoundError: |
| logger.warning(f"Audit log file not found: {self._log_path}") |
| return [] |
|
|
| |
| return list(reversed(entries)) |
|
|
| def get_service_audit_log(self, service: str, limit: int = 100) -> List[Dict]: |
| """ |
| Retrieve audit entries for specific service. |
| |
| Args: |
| service: Service name to query (spotify, sonos, hue, etc.) |
| limit: Maximum number of entries (default: 100) |
| |
| Returns: |
| List of audit log entries (most recent first) |
| """ |
| entries = [] |
|
|
| try: |
| with open(self._log_path, 'r') as f: |
| for line in f: |
| try: |
| entry = json.loads(line.strip()) |
| if entry.get('service') == service: |
| entries.append(entry) |
| if len(entries) >= limit: |
| break |
| except json.JSONDecodeError: |
| continue |
|
|
| except FileNotFoundError: |
| logger.warning(f"Audit log file not found: {self._log_path}") |
| return [] |
|
|
| |
| return list(reversed(entries)) |
|
|
| |
| |
| |
|
|
| def rotate_audit_logs(self): |
| """ |
| Rotate audit logs (compress old logs). |
| |
| Compresses logs older than today with gzip. |
| Should be called daily (e.g., via cron or scheduler). |
| """ |
| today = datetime.utcnow().date() |
|
|
| |
| if not self._log_path.exists(): |
| return |
|
|
| |
| mtime = datetime.fromtimestamp(self._log_path.stat().st_mtime) |
|
|
| |
| if mtime.date() < today: |
| yesterday = mtime.date() |
| rotated_path = self._log_path.with_suffix( |
| f'.{yesterday.strftime("%Y-%m-%d")}.log' |
| ) |
|
|
| |
| self._log_path.rename(rotated_path) |
|
|
| |
| compressed_path = rotated_path.with_suffix('.log.gz') |
| with open(rotated_path, 'rb') as f_in: |
| with gzip.open(compressed_path, 'wb') as f_out: |
| f_out.writelines(f_in) |
|
|
| |
| rotated_path.unlink() |
|
|
| logger.info( |
| "Audit log rotated and compressed", |
| extra={ |
| "rotated_path": str(rotated_path), |
| "compressed_path": str(compressed_path) |
| } |
| ) |
|
|
| |
| self._audit_handler.close() |
| self._setup_file_handler() |
|
|
| def cleanup_old_audit_logs(self): |
| """ |
| Remove audit logs older than retention period. |
| |
| Should be called daily (e.g., via cron or scheduler). |
| """ |
| cutoff_date = datetime.utcnow() - timedelta(days=self._retention_days) |
| removed_count = 0 |
|
|
| |
| log_pattern = self._log_path.stem + ".*.log*" |
| for log_file in self._log_path.parent.glob(log_pattern): |
| try: |
| |
| mtime = datetime.fromtimestamp(log_file.stat().st_mtime) |
|
|
| if mtime < cutoff_date: |
| log_file.unlink() |
| removed_count += 1 |
| logger.info( |
| "Old audit log removed", |
| extra={ |
| "file": str(log_file), |
| "age_days": (datetime.utcnow() - mtime).days |
| } |
| ) |
|
|
| except Exception as e: |
| logger.error( |
| "Failed to remove old audit log", |
| extra={ |
| "file": str(log_file), |
| "error": str(e) |
| } |
| ) |
|
|
| if removed_count > 0: |
| logger.info( |
| "Audit log cleanup complete", |
| extra={"removed_count": removed_count} |
| ) |
|
|
|
|
| |
| |
| |
|
|
| async def log_media_action_async( |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| service: str, |
| details: Dict[str, Any], |
| result: str |
| ): |
| """ |
| Async wrapper for media action logging. |
| |
| Runs logging in background thread to avoid blocking. |
| """ |
| loop = asyncio.get_event_loop() |
| await loop.run_in_executor( |
| None, |
| lambda: AuditLogger().log_media_action( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=action, |
| service=service, |
| details=details, |
| result=result |
| ) |
| ) |
|
|
|
|
| async def log_smarthome_action_async( |
| user_id: str, |
| agent_id: Optional[str], |
| action: str, |
| service: str, |
| details: Dict[str, Any], |
| result: str |
| ): |
| """ |
| Async wrapper for smart home action logging. |
| |
| Runs logging in background thread to avoid blocking. |
| """ |
| loop = asyncio.get_event_loop() |
| await loop.run_in_executor( |
| None, |
| lambda: AuditLogger().log_smarthome_action( |
| user_id=user_id, |
| agent_id=agent_id, |
| action=action, |
| service=service, |
| details=details, |
| result=result |
| ) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| _audit_logger_instance: Optional[AuditLogger] = None |
|
|
|
|
| def get_audit_logger() -> AuditLogger: |
| """ |
| Get singleton AuditLogger instance. |
| |
| Returns: |
| AuditLogger singleton instance |
| """ |
| global _audit_logger_instance |
| if _audit_logger_instance is None: |
| _audit_logger_instance = AuditLogger() |
| return _audit_logger_instance |
|
|