Files
PROJECT-CONTORL/backend/app/api/audit/router.py
beabigegg 9b220523ff feat: complete issue fixes and implement remaining features
## Critical Issues (CRIT-001~003) - All Fixed
- JWT secret key validation with pydantic field_validator
- Login audit logging for success/failure attempts
- Frontend API path prefix removal

## High Priority Issues (HIGH-001~008) - All Fixed
- Project soft delete using is_active flag
- Redis session token bytes handling
- Rate limiting with slowapi (5 req/min for login)
- Attachment API permission checks
- Kanban view with drag-and-drop
- Workload heatmap UI (WorkloadPage, WorkloadHeatmap)
- TaskDetailModal integrating Comments/Attachments
- UserSelect component for task assignment

## Medium Priority Issues (MED-001~012) - All Fixed
- MED-001~005: DB commits, N+1 queries, datetime, error format, blocker flag
- MED-006: Project health dashboard (HealthService, ProjectHealthPage)
- MED-007: Capacity update API (PUT /api/users/{id}/capacity)
- MED-008: Schedule triggers (cron parsing, deadline reminders)
- MED-009: Watermark feature (image/PDF watermarking)
- MED-010~012: useEffect deps, DOM operations, PDF export

## New Files
- backend/app/api/health/ - Project health API
- backend/app/services/health_service.py
- backend/app/services/trigger_scheduler.py
- backend/app/services/watermark_service.py
- backend/app/core/rate_limiter.py
- frontend/src/pages/ProjectHealthPage.tsx
- frontend/src/components/ProjectHealthCard.tsx
- frontend/src/components/KanbanBoard.tsx
- frontend/src/components/WorkloadHeatmap.tsx

## Tests
- 113 new tests passing (health: 32, users: 14, triggers: 35, watermark: 32)

## OpenSpec Archives
- add-project-health-dashboard
- add-capacity-update-api
- add-schedule-triggers
- add-watermark-feature
- add-rate-limiting
- enhance-frontend-ux
- add-resource-management-ui

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:49:52 +08:00

280 lines
9.1 KiB
Python

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)