import csv import io from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from app.core.database import get_db from app.models import User, AuditLog, AuditAlert from app.schemas.audit import ( AuditLogResponse, AuditLogListResponse, AuditAlertResponse, AuditAlertListResponse, IntegrityCheckRequest, IntegrityCheckResponse ) from app.middleware.auth import get_current_user from app.services.audit_service import AuditService router = APIRouter(tags=["audit"]) def audit_log_to_response(log: AuditLog) -> AuditLogResponse: """Convert AuditLog model to AuditLogResponse.""" return AuditLogResponse( id=log.id, event_type=log.event_type, resource_type=log.resource_type, resource_id=log.resource_id, user_id=log.user_id, action=log.action, changes=log.changes, request_metadata=log.request_metadata, sensitivity_level=log.sensitivity_level, checksum=log.checksum, created_at=log.created_at, user_name=log.user.name if log.user else None, user_email=log.user.email if log.user else None, ) def audit_alert_to_response(alert: AuditAlert) -> AuditAlertResponse: """Convert AuditAlert model to AuditAlertResponse.""" return AuditAlertResponse( id=alert.id, audit_log_id=alert.audit_log_id, alert_type=alert.alert_type, recipients=alert.recipients or [], message=alert.message, is_acknowledged=alert.is_acknowledged, acknowledged_by=alert.acknowledged_by, acknowledged_at=alert.acknowledged_at, created_at=alert.created_at, ) def require_admin(current_user: User) -> None: """Raise 403 if user is not a system admin.""" if not current_user.is_system_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required", ) @router.get("/api/audit-logs", response_model=AuditLogListResponse) async def list_audit_logs( start_date: Optional[datetime] = Query(None, description="Filter by start date"), end_date: Optional[datetime] = Query(None, description="Filter by end date"), user_id: Optional[str] = Query(None, description="Filter by user ID"), resource_type: Optional[str] = Query(None, description="Filter by resource type"), resource_id: Optional[str] = Query(None, description="Filter by resource ID"), sensitivity_level: Optional[str] = Query(None, description="Filter by sensitivity level"), limit: int = Query(50, ge=1, le=100, description="Number of logs to return"), offset: int = Query(0, ge=0, description="Offset for pagination"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List audit logs with optional filters. Requires admin access.""" require_admin(current_user) query = db.query(AuditLog) if start_date: query = query.filter(AuditLog.created_at >= start_date) if end_date: query = query.filter(AuditLog.created_at <= end_date) if user_id: query = query.filter(AuditLog.user_id == user_id) if resource_type: query = query.filter(AuditLog.resource_type == resource_type) if resource_id: query = query.filter(AuditLog.resource_id == resource_id) if sensitivity_level: query = query.filter(AuditLog.sensitivity_level == sensitivity_level) total = query.count() logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all() return AuditLogListResponse( logs=[audit_log_to_response(log) for log in logs], total=total, offset=offset, limit=limit, ) @router.get("/api/audit-logs/resource/{resource_type}/{resource_id}", response_model=AuditLogListResponse) async def get_resource_history( resource_type: str, resource_id: str, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Get change history for a specific resource.""" # Note: We allow all authenticated users to view resource history # because this is used in Task/Project detail pages query = db.query(AuditLog).filter( AuditLog.resource_type == resource_type, AuditLog.resource_id == resource_id, ) total = query.count() logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all() return AuditLogListResponse( logs=[audit_log_to_response(log) for log in logs], total=total, offset=offset, limit=limit, ) @router.get("/api/audit-logs/export") async def export_audit_logs( start_date: Optional[datetime] = Query(None), end_date: Optional[datetime] = Query(None), user_id: Optional[str] = Query(None), resource_type: Optional[str] = Query(None), sensitivity_level: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Export audit logs as CSV. Requires admin access.""" require_admin(current_user) query = db.query(AuditLog) if start_date: query = query.filter(AuditLog.created_at >= start_date) if end_date: query = query.filter(AuditLog.created_at <= end_date) if user_id: query = query.filter(AuditLog.user_id == user_id) if resource_type: query = query.filter(AuditLog.resource_type == resource_type) if sensitivity_level: query = query.filter(AuditLog.sensitivity_level == sensitivity_level) logs = query.order_by(AuditLog.created_at.desc()).all() # Generate CSV output = io.StringIO() writer = csv.writer(output) # Header writer.writerow([ "ID", "Event Type", "Resource Type", "Resource ID", "User ID", "User Name", "Action", "Changes", "IP Address", "Sensitivity", "Created At" ]) # Data rows for log in logs: ip_address = log.request_metadata.get("ip_address", "") if log.request_metadata else "" changes_str = str(log.changes) if log.changes else "" writer.writerow([ log.id, log.event_type, log.resource_type, log.resource_id or "", log.user_id or "", log.user.name if log.user else "", log.action, changes_str, ip_address, log.sensitivity_level, log.created_at.isoformat(), ]) output.seek(0) filename = f"audit_logs_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv" return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"} ) @router.post("/api/audit-logs/verify-integrity", response_model=IntegrityCheckResponse) async def verify_integrity( check_request: IntegrityCheckRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Verify integrity of audit logs within a date range. Requires admin access.""" require_admin(current_user) logs = db.query(AuditLog).filter( AuditLog.created_at >= check_request.start_date, AuditLog.created_at <= check_request.end_date, ).all() valid_count = 0 invalid_records = [] for log in logs: if AuditService.verify_checksum(log): valid_count += 1 else: invalid_records.append(log.id) return IntegrityCheckResponse( total_checked=len(logs), valid_count=valid_count, invalid_count=len(invalid_records), invalid_records=invalid_records, ) @router.get("/api/audit-alerts", response_model=AuditAlertListResponse) async def list_audit_alerts( is_acknowledged: Optional[bool] = Query(None, description="Filter by acknowledgment status"), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List audit alerts. Requires admin access.""" require_admin(current_user) query = db.query(AuditAlert) if is_acknowledged is not None: query = query.filter(AuditAlert.is_acknowledged == is_acknowledged) total = query.count() alerts = query.order_by(AuditAlert.created_at.desc()).offset(offset).limit(limit).all() return AuditAlertListResponse( alerts=[audit_alert_to_response(alert) for alert in alerts], total=total, ) @router.put("/api/audit-alerts/{alert_id}/acknowledge", response_model=AuditAlertResponse) async def acknowledge_alert( alert_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Acknowledge an audit alert. Requires admin access.""" require_admin(current_user) alert = AuditService.acknowledge_alert(db, alert_id, current_user.id) if not alert: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Alert not found", ) db.commit() db.refresh(alert) return audit_alert_to_response(alert)