473 lines
16 KiB
Python
473 lines
16 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Audit Logging System for PowerOn Gateway
|
|
|
|
This module provides centralized audit logging functionality for security events,
|
|
user actions, and system access patterns. Logs are stored in the database for
|
|
GDPR compliance and security monitoring.
|
|
|
|
GDPR Requirements Addressed:
|
|
- Article 5(1)(f): Integrity and confidentiality - secure audit trail
|
|
- Article 17: Right to erasure - audit log retention with automatic cleanup
|
|
- Article 30: Records of processing activities - comprehensive event logging
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional, Dict, Any
|
|
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.shared.timeUtils import getUtcTimestamp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuditLogger:
|
|
"""
|
|
Centralized audit logging system with database storage.
|
|
|
|
Logs security-relevant events to PostgreSQL for:
|
|
- GDPR compliance
|
|
- Security monitoring
|
|
- Access tracking
|
|
- Incident investigation
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._db = None
|
|
self._modelClass = None
|
|
self._initialized = False
|
|
self._fallbackToStdout = True
|
|
|
|
def _ensureInitialized(self) -> bool:
|
|
"""Lazily initialize database connection to avoid circular imports."""
|
|
if self._initialized:
|
|
return self._db is not None
|
|
|
|
self._initialized = True
|
|
|
|
try:
|
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
|
|
self._modelClass = AuditLogEntry
|
|
|
|
# Get database configuration
|
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
|
dbDatabase = "poweron_app" # Store audit logs in the main app database
|
|
dbUser = APP_CONFIG.get("DB_USER")
|
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
|
|
|
# Create database connector with system user context
|
|
self._db = DatabaseConnector(
|
|
dbHost=dbHost,
|
|
dbDatabase=dbDatabase,
|
|
dbUser=dbUser,
|
|
dbPassword=dbPassword,
|
|
dbPort=dbPort,
|
|
userId="system" # Audit logs are created by system
|
|
)
|
|
|
|
# Initialize database and ensure table exists
|
|
self._db.initDbSystem()
|
|
self._db._ensureTableExists(AuditLogEntry)
|
|
|
|
logger.info("AuditLogger database connection initialized successfully")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.warning(f"AuditLogger database initialization failed, using fallback logging: {e}")
|
|
self._db = None
|
|
return False
|
|
|
|
def _logToFallback(self, entry: Dict[str, Any]) -> None:
|
|
"""Log to standard logger as fallback when database is unavailable."""
|
|
if self._fallbackToStdout:
|
|
fallbackMsg = (
|
|
f"AUDIT | {entry.get('timestamp', '')} | "
|
|
f"{entry.get('userId', '')} | {entry.get('mandateId', '')} | "
|
|
f"{entry.get('category', '')} | {entry.get('action', '')} | "
|
|
f"{entry.get('details', '')}"
|
|
)
|
|
logging.getLogger('audit.fallback').info(fallbackMsg)
|
|
|
|
def logEvent(
|
|
self,
|
|
userId: str,
|
|
mandateId: Optional[str] = None,
|
|
category: str = "system",
|
|
action: str = "",
|
|
details: str = "",
|
|
featureInstanceId: Optional[str] = None,
|
|
resourceType: Optional[str] = None,
|
|
resourceId: Optional[str] = None,
|
|
ipAddress: Optional[str] = None,
|
|
userAgent: Optional[str] = None,
|
|
success: bool = True,
|
|
errorMessage: Optional[str] = None,
|
|
username: Optional[str] = None,
|
|
timestamp: Optional[float] = None
|
|
) -> Optional[str]:
|
|
"""
|
|
Log an audit event to the database.
|
|
|
|
Args:
|
|
userId: User identifier (or 'system' for system events)
|
|
mandateId: Mandate context (can be None for system-level events)
|
|
category: Event category (access, key, data, security, gdpr, permission, system)
|
|
action: Specific action performed
|
|
details: Additional details about the event
|
|
featureInstanceId: Feature instance context (if applicable)
|
|
resourceType: Type of resource affected
|
|
resourceId: ID of the affected resource
|
|
ipAddress: Client IP address
|
|
userAgent: Client user agent
|
|
success: Whether the action was successful
|
|
errorMessage: Error message if action failed
|
|
username: Username at the time of event (for historical reference)
|
|
timestamp: Optional custom timestamp (defaults to current time)
|
|
|
|
Returns:
|
|
ID of the created audit log entry, or None if logging failed
|
|
"""
|
|
try:
|
|
# Prepare the entry data
|
|
entryData = {
|
|
"timestamp": timestamp if timestamp else getUtcTimestamp(),
|
|
"userId": userId or "unknown",
|
|
"username": username,
|
|
"mandateId": mandateId,
|
|
"featureInstanceId": featureInstanceId,
|
|
"category": category,
|
|
"action": action,
|
|
"resourceType": resourceType,
|
|
"resourceId": resourceId,
|
|
"details": details if details else None,
|
|
"ipAddress": ipAddress,
|
|
"userAgent": userAgent,
|
|
"success": success,
|
|
"errorMessage": errorMessage
|
|
}
|
|
|
|
# Try to write to database
|
|
if self._ensureInitialized() and self._db:
|
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
|
|
|
entry = AuditLogEntry(**entryData)
|
|
created = self._db.recordCreate(AuditLogEntry, entry.model_dump())
|
|
|
|
if created and created.get("id"):
|
|
return created["id"]
|
|
else:
|
|
self._logToFallback(entryData)
|
|
return None
|
|
else:
|
|
# Use fallback logging
|
|
self._logToFallback(entryData)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to log audit event: {e}")
|
|
# Try fallback
|
|
try:
|
|
self._logToFallback(entryData)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
# ===== Convenience Methods for Common Event Types =====
|
|
|
|
def logKeyAccess(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
keyName: str,
|
|
action: str,
|
|
ipAddress: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Log key access events (encode/decode)."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="key",
|
|
action=action,
|
|
details=f"Key: {keyName}",
|
|
resourceType="EncryptionKey",
|
|
resourceId=keyName,
|
|
ipAddress=ipAddress
|
|
)
|
|
|
|
def logUserAccess(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
action: str,
|
|
successInfo: str = "",
|
|
ipAddress: Optional[str] = None,
|
|
userAgent: Optional[str] = None,
|
|
success: bool = True
|
|
) -> Optional[str]:
|
|
"""Log user access events (login/logout)."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="access",
|
|
action=action,
|
|
details=successInfo,
|
|
ipAddress=ipAddress,
|
|
userAgent=userAgent,
|
|
success=success
|
|
)
|
|
|
|
def logDataAccess(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
action: str,
|
|
details: str = "",
|
|
resourceType: Optional[str] = None,
|
|
resourceId: Optional[str] = None,
|
|
featureInstanceId: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Log data access events (CRUD operations)."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="data",
|
|
action=action,
|
|
details=details,
|
|
resourceType=resourceType,
|
|
resourceId=resourceId,
|
|
featureInstanceId=featureInstanceId
|
|
)
|
|
|
|
def logSecurityEvent(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
action: str,
|
|
details: str = "",
|
|
ipAddress: Optional[str] = None,
|
|
success: bool = True,
|
|
errorMessage: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Log security-related events."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="security",
|
|
action=action,
|
|
details=details,
|
|
ipAddress=ipAddress,
|
|
success=success,
|
|
errorMessage=errorMessage
|
|
)
|
|
|
|
def logGdprEvent(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
action: str,
|
|
details: str = "",
|
|
ipAddress: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Log GDPR-specific events (data export, deletion, etc.)."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="gdpr",
|
|
action=action,
|
|
details=details,
|
|
ipAddress=ipAddress
|
|
)
|
|
|
|
def logPermissionChange(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
action: str,
|
|
targetUserId: str,
|
|
details: str = "",
|
|
resourceType: Optional[str] = None,
|
|
resourceId: Optional[str] = None
|
|
) -> Optional[str]:
|
|
"""Log permission/role changes."""
|
|
return self.logEvent(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
category="permission",
|
|
action=action,
|
|
details=f"Target user: {targetUserId}. {details}",
|
|
resourceType=resourceType,
|
|
resourceId=resourceId
|
|
)
|
|
|
|
# ===== Audit Log Query Methods =====
|
|
|
|
def getAuditLogs(
|
|
self,
|
|
userId: Optional[str] = None,
|
|
mandateId: Optional[str] = None,
|
|
category: Optional[str] = None,
|
|
action: Optional[str] = None,
|
|
fromTimestamp: Optional[float] = None,
|
|
toTimestamp: Optional[float] = None,
|
|
limit: int = 100
|
|
) -> list:
|
|
"""
|
|
Query audit logs from database.
|
|
|
|
Args:
|
|
userId: Filter by user ID
|
|
mandateId: Filter by mandate ID
|
|
category: Filter by category
|
|
action: Filter by action
|
|
fromTimestamp: Filter events after this timestamp
|
|
toTimestamp: Filter events before this timestamp
|
|
limit: Maximum number of records to return
|
|
|
|
Returns:
|
|
List of audit log entries
|
|
"""
|
|
if not self._ensureInitialized() or not self._db:
|
|
return []
|
|
|
|
try:
|
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
|
|
|
# Build filter
|
|
recordFilter = {}
|
|
if userId:
|
|
recordFilter["userId"] = userId
|
|
if mandateId:
|
|
recordFilter["mandateId"] = mandateId
|
|
if category:
|
|
recordFilter["category"] = category
|
|
if action:
|
|
recordFilter["action"] = action
|
|
|
|
# Query database
|
|
records = self._db.getRecordset(
|
|
AuditLogEntry,
|
|
recordFilter=recordFilter if recordFilter else None,
|
|
)
|
|
|
|
# Apply timestamp filtering in Python (PostgreSQL connector may not support $gt/$lt)
|
|
if fromTimestamp or toTimestamp:
|
|
filteredRecords = []
|
|
for record in records:
|
|
ts = record.get("timestamp", 0)
|
|
if fromTimestamp and ts < fromTimestamp:
|
|
continue
|
|
if toTimestamp and ts > toTimestamp:
|
|
continue
|
|
filteredRecords.append(record)
|
|
records = filteredRecords
|
|
|
|
records.sort(key=lambda r: r.get("timestamp", 0), reverse=True)
|
|
return records[:limit]
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to query audit logs: {e}")
|
|
return []
|
|
|
|
# ===== Cleanup Methods =====
|
|
|
|
def cleanupOldEntries(self, retentionDays: int = 365) -> int:
|
|
"""
|
|
Remove audit log entries older than the retention period.
|
|
|
|
GDPR Note: Audit logs should be retained for a reasonable period
|
|
for security and compliance purposes, but not indefinitely.
|
|
Default retention is 1 year (365 days).
|
|
|
|
Args:
|
|
retentionDays: Number of days to retain audit logs
|
|
|
|
Returns:
|
|
Number of entries deleted
|
|
"""
|
|
if not self._ensureInitialized() or not self._db:
|
|
logger.warning("Cannot cleanup audit logs: database not initialized")
|
|
return 0
|
|
|
|
try:
|
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
|
import time
|
|
|
|
# Calculate cutoff timestamp
|
|
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
|
|
|
|
# Query old entries
|
|
allRecords = self._db.getRecordset(AuditLogEntry)
|
|
oldRecords = [r for r in allRecords if r.get("timestamp", 0) < cutoffTimestamp]
|
|
|
|
# Delete old entries
|
|
deletedCount = 0
|
|
for record in oldRecords:
|
|
recordId = record.get("id")
|
|
if recordId:
|
|
if self._db.recordDelete(AuditLogEntry, recordId):
|
|
deletedCount += 1
|
|
|
|
logger.info(f"Audit log cleanup: removed {deletedCount} entries older than {retentionDays} days")
|
|
|
|
# Log the cleanup action itself
|
|
self.logEvent(
|
|
userId="system",
|
|
mandateId="system",
|
|
category="system",
|
|
action="audit_cleanup",
|
|
details=f"Removed {deletedCount} entries older than {retentionDays} days"
|
|
)
|
|
|
|
return deletedCount
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to cleanup audit logs: {e}")
|
|
return 0
|
|
|
|
|
|
# Global audit logger instance
|
|
audit_logger = AuditLogger()
|
|
|
|
|
|
# ===== Scheduler Integration =====
|
|
|
|
async def runAuditLogCleanup() -> None:
|
|
"""
|
|
Scheduled task to cleanup old audit log entries.
|
|
Called by the event scheduler.
|
|
"""
|
|
try:
|
|
retentionDays = int(APP_CONFIG.get("AUDIT_LOG_RETENTION_DAYS", 365))
|
|
deletedCount = audit_logger.cleanupOldEntries(retentionDays=retentionDays)
|
|
logger.info(f"Scheduled audit log cleanup completed: {deletedCount} entries removed")
|
|
except Exception as e:
|
|
logger.error(f"Scheduled audit log cleanup failed: {e}")
|
|
|
|
|
|
def registerAuditLogCleanupScheduler() -> None:
|
|
"""
|
|
Register the audit log cleanup job with the event scheduler.
|
|
Should be called during application startup.
|
|
"""
|
|
try:
|
|
from modules.shared.eventManagement import eventManager
|
|
|
|
# Run cleanup daily at 3 AM
|
|
eventManager.registerCron(
|
|
jobId="audit_log_cleanup",
|
|
func=runAuditLogCleanup,
|
|
cronKwargs={
|
|
"hour": "3",
|
|
"minute": "0"
|
|
}
|
|
)
|
|
|
|
logger.info("Audit log cleanup scheduler registered (daily at 03:00)")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to register audit log cleanup scheduler: {e}")
|