| """
|
| Audit Logging Service
|
|
|
| Provides audit logging functionality for security and compliance.
|
| Immutable entries - append only, no updates allowed.
|
| """
|
|
|
| import uuid
|
| from datetime import datetime
|
| from typing import Any, Dict, Optional
|
| from enum import Enum
|
|
|
| from sqlalchemy import select
|
| from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
| from backend.db.models import AuditLog, User, Tenant
|
| from backend.db.session import get_db_session
|
|
|
|
|
| class AuditAction(str, Enum):
|
| """Standard audit action types."""
|
|
|
| JOB_CREATED = "JOB_CREATED"
|
| JOB_STARTED = "JOB_STARTED"
|
| JOB_COMPLETED = "JOB_COMPLETED"
|
| JOB_FAILED = "JOB_FAILED"
|
| JOB_CANCELLED = "JOB_CANCELLED"
|
| JOB_DELETED = "JOB_DELETED"
|
|
|
|
|
| USER_CREATED = "USER_CREATED"
|
| USER_UPDATED = "USER_UPDATED"
|
| USER_DELETED = "USER_DELETED"
|
| USER_LOGIN = "USER_LOGIN"
|
| USER_LOGOUT = "USER_LOGOUT"
|
| ROLE_CHANGED = "ROLE_CHANGED"
|
|
|
|
|
| API_KEY_CREATED = "API_KEY_CREATED"
|
| API_KEY_DELETED = "API_KEY_DELETED"
|
| API_KEY_ROTATED = "API_KEY_ROTATED"
|
|
|
|
|
| RELEASE_APPROVED = "RELEASE_APPROVED"
|
| RELEASE_REJECTED = "RELEASE_REJECTED"
|
| RELEASE_BLOCKED = "RELEASE_BLOCKED"
|
|
|
|
|
| REPORT_CREATED = "REPORT_CREATED"
|
| REPORT_EXPORTED = "REPORT_EXPORTED"
|
| REPORT_DELETED = "REPORT_DELETED"
|
|
|
|
|
| ALERT_CREATED = "ALERT_CREATED"
|
| ALERT_RESOLVED = "ALERT_RESOLVED"
|
| BASELINE_UPDATED = "BASELINE_UPDATED"
|
|
|
|
|
| TENANT_CREATED = "TENANT_CREATED"
|
| TENANT_UPDATED = "TENANT_UPDATED"
|
| TENANT_DEACTIVATED = "TENANT_DEACTIVATED"
|
|
|
|
|
| MODEL_REGISTERED = "MODEL_REGISTERED"
|
| DATASET_REGISTERED = "DATASET_REGISTERED"
|
|
|
|
|
| class ResourceType(str, Enum):
|
| """Resource types for audit logging."""
|
| JOB = "job"
|
| USER = "user"
|
| API_KEY = "api_key"
|
| RELEASE = "release"
|
| REPORT = "report"
|
| ALERT = "alert"
|
| TENANT = "tenant"
|
| MODEL = "model"
|
| DATASET = "dataset"
|
|
|
|
|
| class AuditLogger:
|
| """
|
| Audit logging service for tracking user actions.
|
|
|
| All entries are immutable - append only, no updates allowed.
|
| """
|
|
|
| def __init__(self, db_session: AsyncSession):
|
| self.db = db_session
|
|
|
| async def log(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| action: AuditAction,
|
| resource_type: ResourceType,
|
| resource_id: Optional[uuid.UUID] = None,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| request_id: Optional[str] = None,
|
| ip_address: Optional[str] = None,
|
| status: str = "success",
|
| error_message: Optional[str] = None,
|
| ) -> AuditLog:
|
| """
|
| Create an audit log entry.
|
|
|
| Args:
|
| tenant_id: Tenant ID
|
| user_id: User who performed the action
|
| action: Action performed
|
| resource_type: Type of resource affected
|
| resource_id: ID of the affected resource
|
| metadata: Additional action metadata
|
| request_id: Request ID for tracing
|
| ip_address: Client IP address
|
| status: Action status (success, failure, pending)
|
| error_message: Error message if action failed
|
|
|
| Returns:
|
| Created AuditLog entry
|
| """
|
| audit_log = AuditLog(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=action.value,
|
| resource_type=resource_type.value,
|
| resource_id=resource_id,
|
| metadata=metadata,
|
| request_id=request_id,
|
| ip_address=ip_address,
|
| status=status,
|
| error_message=error_message,
|
| timestamp=datetime.utcnow(),
|
| )
|
|
|
| self.db.add(audit_log)
|
| await self.db.commit()
|
| await self.db.refresh(audit_log)
|
|
|
| return audit_log
|
|
|
| async def log_job_created(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| job_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log job creation."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.JOB_CREATED,
|
| resource_type=ResourceType.JOB,
|
| resource_id=job_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def log_job_completed(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| job_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log job completion."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.JOB_COMPLETED,
|
| resource_type=ResourceType.JOB,
|
| resource_id=job_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def log_job_failed(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| job_id: uuid.UUID,
|
| error_message: str,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log job failure."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.JOB_FAILED,
|
| resource_type=ResourceType.JOB,
|
| resource_id=job_id,
|
| metadata=metadata,
|
| status="failure",
|
| error_message=error_message,
|
| **kwargs,
|
| )
|
|
|
| async def log_api_key_created(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| api_key_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log API key creation."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.API_KEY_CREATED,
|
| resource_type=ResourceType.API_KEY,
|
| resource_id=api_key_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def log_release_approved(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| release_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log release approval."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.RELEASE_APPROVED,
|
| resource_type=ResourceType.RELEASE,
|
| resource_id=release_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def log_user_login(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log user login."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.USER_LOGIN,
|
| resource_type=ResourceType.USER,
|
| resource_id=user_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def log_role_changed(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| target_user_id: uuid.UUID,
|
| old_role: str,
|
| new_role: str,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log role change."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.ROLE_CHANGED,
|
| resource_type=ResourceType.USER,
|
| resource_id=target_user_id,
|
| metadata={"old_role": old_role, "new_role": new_role},
|
| **kwargs,
|
| )
|
|
|
| async def log_report_exported(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| report_id: uuid.UUID,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| **kwargs,
|
| ) -> AuditLog:
|
| """Log report export."""
|
| return await self.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.REPORT_EXPORTED,
|
| resource_type=ResourceType.REPORT,
|
| resource_id=report_id,
|
| metadata=metadata,
|
| **kwargs,
|
| )
|
|
|
| async def get_tenant_audit_logs(
|
| self,
|
| tenant_id: uuid.UUID,
|
| limit: int = 100,
|
| offset: int = 0,
|
| action: Optional[AuditAction] = None,
|
| user_id: Optional[uuid.UUID] = None,
|
| resource_type: Optional[ResourceType] = None,
|
| ) -> list[AuditLog]:
|
| """
|
| Get audit logs for a tenant.
|
|
|
| Args:
|
| tenant_id: Tenant ID
|
| limit: Maximum number of entries to return
|
| offset: Number of entries to skip
|
| action: Filter by action type
|
| user_id: Filter by user
|
| resource_type: Filter by resource type
|
|
|
| Returns:
|
| List of audit log entries
|
| """
|
| query = select(AuditLog).where(
|
| AuditLog.tenant_id == tenant_id
|
| )
|
|
|
| if action:
|
| query = query.where(AuditLog.action == action.value)
|
|
|
| if user_id:
|
| query = query.where(AuditLog.user_id == user_id)
|
|
|
| if resource_type:
|
| query = query.where(AuditLog.resource_type == resource_type.value)
|
|
|
| query = (
|
| query
|
| .order_by(AuditLog.timestamp.desc())
|
| .limit(limit)
|
| .offset(offset)
|
| )
|
|
|
| result = await self.db.execute(query)
|
| return list(result.scalars().all())
|
|
|
|
|
| async def create_audit_log(
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| action: AuditAction,
|
| resource_type: ResourceType,
|
| resource_id: Optional[uuid.UUID] = None,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| request_id: Optional[str] = None,
|
| ip_address: Optional[str] = None,
|
| status: str = "success",
|
| error_message: Optional[str] = None,
|
| ) -> AuditLog:
|
| """
|
| Convenience function to create an audit log entry.
|
|
|
| Creates a new database session for the operation.
|
| """
|
| async with get_db_session() as session:
|
| logger = AuditLogger(session)
|
| return await logger.log(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=action,
|
| resource_type=resource_type,
|
| resource_id=resource_id,
|
| metadata=metadata,
|
| request_id=request_id,
|
| ip_address=ip_address,
|
| status=status,
|
| error_message=error_message,
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
| class AuditContext:
|
| """
|
| Context manager for automatic audit logging.
|
|
|
| Usage:
|
| async with AuditContext(
|
| tenant_id=tenant_id,
|
| user_id=user_id,
|
| action=AuditAction.JOB_CREATED,
|
| resource_type=ResourceType.JOB,
|
| resource_id=job_id,
|
| ) as audit:
|
| # Do work
|
| await process_job(job_id)
|
| # Success automatically logged on exit
|
| """
|
|
|
| def __init__(
|
| self,
|
| tenant_id: uuid.UUID,
|
| user_id: uuid.UUID,
|
| action: AuditAction,
|
| resource_type: ResourceType,
|
| resource_id: Optional[uuid.UUID] = None,
|
| metadata: Optional[Dict[str, Any]] = None,
|
| request_id: Optional[str] = None,
|
| ip_address: Optional[str] = None,
|
| ):
|
| self.tenant_id = tenant_id
|
| self.user_id = user_id
|
| self.action = action
|
| self.resource_type = resource_type
|
| self.resource_id = resource_id
|
| self.metadata = metadata or {}
|
| self.request_id = request_id
|
| self.ip_address = ip_address
|
| self.status = "success"
|
| self.error_message = None
|
| self._audit_log: Optional[AuditLog] = None
|
|
|
| async def __aenter__(self) -> "AuditContext":
|
| return self
|
|
|
| async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| if exc_type is not None:
|
| self.status = "failure"
|
| self.error_message = str(exc_val) if exc_val else "Unknown error"
|
|
|
|
|
| async with get_db_session() as session:
|
| logger = AuditLogger(session)
|
| self._audit_log = await logger.log(
|
| tenant_id=self.tenant_id,
|
| user_id=self.user_id,
|
| action=self.action,
|
| resource_type=self.resource_type,
|
| resource_id=self.resource_id,
|
| metadata=self.metadata,
|
| request_id=self.request_id,
|
| ip_address=self.ip_address,
|
| status=self.status,
|
| error_message=self.error_message,
|
| )
|
|
|
| return False
|
|
|
| @property
|
| def audit_log(self) -> Optional[AuditLog]:
|
| """Get the created audit log entry."""
|
| return self._audit_log
|
|
|