feat: add TLS reporting posture summaries
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_admin_auth
|
||||
from app.models.domain import Domain
|
||||
from app.models.report import TLSReport
|
||||
from app.services.tls_report_parser import MAX_TLS_REPORT_SIZE, TLSReportParser
|
||||
from app.services.tls_report_persistence import (
|
||||
TLS_REPORT_PRIVACY_CONTROLS,
|
||||
save_tls_report,
|
||||
summarize_tls_reports,
|
||||
tls_report_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TLSFailureResponse(BaseModel):
|
||||
result_type: str
|
||||
failed_session_count: int
|
||||
sending_mta_ip: Optional[str] = None
|
||||
receiving_mx_hostname: Optional[str] = None
|
||||
receiving_mx_helo: Optional[str] = None
|
||||
receiving_ip: Optional[str] = None
|
||||
failure_reason_code: Optional[str] = None
|
||||
additional_information: Optional[str] = None
|
||||
|
||||
|
||||
class TLSReportResponse(BaseModel):
|
||||
id: int
|
||||
report_id: str
|
||||
domain: Optional[str] = None
|
||||
org_name: Optional[str] = None
|
||||
contact_info: Optional[str] = None
|
||||
policy_domain: str
|
||||
policy_type: Optional[str] = None
|
||||
begin_date: Optional[str] = None
|
||||
end_date: Optional[str] = None
|
||||
total_successful_sessions: int
|
||||
total_failure_sessions: int
|
||||
processed_at: Optional[str] = None
|
||||
failures: List[TLSFailureResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TLSReportListResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
reports: List[TLSReportResponse]
|
||||
privacy: Dict[str, Any]
|
||||
|
||||
|
||||
class TLSReportUploadResponse(BaseModel):
|
||||
success: bool
|
||||
report_id: str
|
||||
policies_created: int
|
||||
policies_skipped: int
|
||||
duplicate: bool = False
|
||||
message: str
|
||||
privacy: Dict[str, Any]
|
||||
|
||||
|
||||
class TLSSummaryResponse(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
days: int
|
||||
totals: Dict[str, Any]
|
||||
trends: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
top_failures: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
affected_domains: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
privacy: Dict[str, Any]
|
||||
|
||||
|
||||
def _validate_upload(file: UploadFile, content: bytes) -> None:
|
||||
filename = file.filename or ""
|
||||
if not filename:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||||
if len(content) == 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")
|
||||
if len(content) > MAX_TLS_REPORT_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File too large",
|
||||
)
|
||||
if not filename.lower().endswith((".json", ".json.gz", ".gzip", ".zip")):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid file type. Upload a TLS report as .json, .json.gz, or .zip.",
|
||||
)
|
||||
|
||||
|
||||
def _filtered_tls_query(db: Session, *, domain: Optional[str] = None):
|
||||
query = db.query(TLSReport).options(
|
||||
selectinload(TLSReport.domain), selectinload(TLSReport.failures)
|
||||
)
|
||||
if domain:
|
||||
normalized = domain.lower().strip(".")
|
||||
query = query.outerjoin(Domain).filter(
|
||||
(Domain.name == normalized) | (TLSReport.policy_domain == normalized)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
@router.post("/upload", response_model=TLSReportUploadResponse)
|
||||
async def upload_tls_report(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
):
|
||||
"""Upload and store an SMTP TLS Reporting aggregate."""
|
||||
try:
|
||||
content = await file.read()
|
||||
_validate_upload(file, content)
|
||||
parsed = TLSReportParser.parse_file(content, file.filename or "")
|
||||
result = save_tls_report(db, parsed)
|
||||
db.commit()
|
||||
return TLSReportUploadResponse(
|
||||
success=True,
|
||||
report_id=parsed["report_id"],
|
||||
policies_created=result["created"],
|
||||
policies_skipped=result["skipped"],
|
||||
duplicate=result["created"] == 0 and result["skipped"] > 0,
|
||||
message=(
|
||||
"TLS report imported."
|
||||
if result["created"]
|
||||
else "TLS report had already been imported."
|
||||
),
|
||||
privacy=TLS_REPORT_PRIVACY_CONTROLS,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc) or "Invalid TLS report format.",
|
||||
) from exc
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Unexpected TLS report upload failure for %s: %s", file.filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Error processing TLS report.",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("", response_model=TLSReportListResponse)
|
||||
async def list_tls_reports(
|
||||
domain: Optional[str] = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
):
|
||||
"""List stored SMTP TLS reports, newest first."""
|
||||
query = _filtered_tls_query(db, domain=domain)
|
||||
total = query.count()
|
||||
rows = (
|
||||
query.order_by(TLSReport.begin_date.desc().nullslast(), TLSReport.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
total_pages = (total + page_size - 1) // page_size if total else 0
|
||||
return TLSReportListResponse(
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=total_pages,
|
||||
reports=[TLSReportResponse(**tls_report_to_dict(row)) for row in rows],
|
||||
privacy=TLS_REPORT_PRIVACY_CONTROLS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=TLSSummaryResponse)
|
||||
async def tls_report_summary(
|
||||
domain: Optional[str] = Query(default=None),
|
||||
days: int = Query(default=30, ge=1, le=365),
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
):
|
||||
"""Summarize TLS reports into trends and top failure causes."""
|
||||
return TLSSummaryResponse(**summarize_tls_reports(db, domain=domain, days=days, limit=limit))
|
||||
Reference in New Issue
Block a user