Files
PROJECT-CONTORL/backend/app/api/audit/router.py
beabigegg 0ef78e13ff feat: implement audit trail module
- Backend (FastAPI):
  - AuditLog and AuditAlert models with Alembic migration
  - AuditService with SHA-256 checksum for log integrity
  - AuditMiddleware for request metadata extraction (IP, user_agent)
  - Integrated audit logging into Task, Project, Blocker APIs
  - Query API with filtering, pagination, CSV export
  - Integrity verification endpoint
  - Sensitive operation alerts with acknowledgement

- Frontend (React + Vite):
  - Admin AuditPage with filters and export
  - ResourceHistory component for change tracking
  - Audit service for API calls

- Testing:
  - 15 tests covering service and API endpoints

- OpenSpec:
  - add-audit-trail change archived

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 21:21:18 +08:00

280 lines
9.1 KiB
Python

import csv
import io
from datetime import datetime
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.utcnow().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)