Files
gh-christianlouis-dmarq/backend/app/api/api_v1/endpoints/domains.py
T

435 lines
13 KiB
Python

import random # Used for mock data generation - TODO: Replace with actual historical data
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from app.services.report_store import ReportStore
from fastapi import APIRouter, HTTPException, Path, Query, status
from pydantic import BaseModel
router = APIRouter()
class DomainBase(BaseModel):
"""Base Domain schema"""
name: str
description: Optional[str] = None
policy: Optional[str] = None
class DomainResponse(DomainBase):
"""Domain response schema"""
reports_count: int = 0
emails_count: int = 0
compliance_rate: float = 0.0
class DomainStatsResponse(BaseModel):
"""Domain statistics for the domain details page"""
complianceRate: float
totalEmails: int
failedEmails: int
reportCount: int
class DNSRecordResponse(BaseModel):
"""DNS record information for a domain"""
dmarc: bool
dmarcRecord: Optional[str] = None
spf: bool
spfRecord: Optional[str] = None
dkim: bool
dkimSelectors: Optional[str] = None
class TimelinePoint(BaseModel):
"""Data point for compliance timeline"""
date: str
compliance_rate: float
class ReportEntry(BaseModel):
"""Summary of a DMARC report"""
id: str
org_name: str
begin_date: int
end_date: int
total_emails: int
pass_rate: float
policy: str
class SourceEntry(BaseModel):
"""Summary of a sending source"""
ip: str
count: int
spf: str
dkim: str
dmarc: str
disposition: str
class DomainReportsResponse(BaseModel):
"""Domain reports with compliance timeline"""
reports: List[ReportEntry]
compliance_timeline: List[TimelinePoint]
class DomainSourcesResponse(BaseModel):
"""Domain sending sources"""
sources: List[SourceEntry]
class DomainSummaryResponse(BaseModel):
"""Domain summary for dashboard"""
total_domains: int
total_emails: int
overall_pass_rate: float
reports_processed: int
domains: List[Dict[str, Any]]
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary():
"""
Get summary statistics for all domains, formatted for the dashboard.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
# Calculate overall statistics
total_domains = len(domains)
total_emails = 0
total_passed = 0
total_reports = 0
domains_list = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
total_emails += summary.get("total_count", 0)
total_passed += summary.get("passed_count", 0)
total_reports += summary.get("reports_processed", 0)
# Format domain data for frontend
domains_list.append(
{
"id": domain_name, # Using the domain name as ID for now
"domain_name": domain_name,
"total_emails": summary.get("total_count", 0),
"passed_count": summary.get("passed_count", 0),
"failed_count": summary.get("failed_count", 0),
"pass_rate": summary.get("compliance_rate", 0),
"report_count": summary.get("reports_processed", 0),
}
)
# Calculate overall pass rate
overall_pass_rate = 0
if total_emails > 0:
overall_pass_rate = round((total_passed / total_emails) * 100, 1)
return DomainSummaryResponse(
total_domains=total_domains,
total_emails=total_emails,
overall_pass_rate=overall_pass_rate,
reports_processed=total_reports,
domains=domains_list,
)
@router.get("/domains", response_model=List[DomainResponse])
async def read_domains():
"""
Retrieve domains with their statistics.
For Milestone 1, this simply returns domains from the in-memory store.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
result = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
domain_response = DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0),
)
result.append(domain_response)
return result
@router.get("/domains/{domain_name}", response_model=DomainResponse)
async def read_domain(domain_name: str):
"""
Get statistics for a specific domain.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_name not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
summary = store.get_domain_summary(domain_name)
return DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0),
)
# New endpoints for domain details page
@router.get("/{domain_id}/stats", response_model=DomainStatsResponse)
async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or name")):
"""
Get detailed statistics for a specific domain
"""
store = ReportStore.get_instance()
domains = store.get_domains()
# For Milestone 1, domain_id is simply the domain name
if domain_id not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
summary = store.get_domain_summary(domain_id)
total_count = summary.get("total_count", 0)
passed_count = summary.get("passed_count", 0)
failed_count = total_count - passed_count
compliance_rate = summary.get("compliance_rate", 0.0)
reports_processed = summary.get("reports_processed", 0)
return DomainStatsResponse(
complianceRate=compliance_rate,
totalEmails=total_count,
failedEmails=failed_count,
reportCount=reports_processed,
)
@router.get("/{domain_id}/dns", response_model=DNSRecordResponse)
async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID or name")):
"""
Get DNS records for a specific domain. For Milestone 1,
this returns mock data since DNS integration is part of a future milestone.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_id not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
# For Milestone 1, return mock DNS record data
# In a future milestone, this will be replaced with actual DNS lookups
mock_dmarc_record = (
"v=DMARC1; p=none; rua=mailto:dmarc@example.com;"
" ruf=mailto:forensic@example.com; pct=100"
)
return DNSRecordResponse(
dmarc=True,
dmarcRecord=mock_dmarc_record,
spf=True,
spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
dkim=True,
dkimSelectors="selector1, selector2",
)
@router.get("/{domain_id}/reports", response_model=DomainReportsResponse)
async def get_domain_reports(
domain_id: str = Path(..., title="The domain ID or name"),
limit: int = Query(10, title="Maximum number of reports to return"),
):
"""
Get recent DMARC reports for a specific domain, along with compliance timeline
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_id not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
# Get reports for this domain
reports = store.get_domain_reports(domain_id, limit=limit)
# Generate report entries
report_entries = []
for report in reports:
report_entries.append(
ReportEntry(
id=report.get("report_id", "unknown"),
org_name=report.get("org_name", "Unknown Organization"),
begin_date=report.get("begin_date", 0),
end_date=report.get("end_date", 0),
total_emails=report.get("total_count", 0),
pass_rate=report.get("pass_rate", 0.0),
policy=report.get("policy", "none"),
)
)
# Generate compliance timeline (last 30 days)
timeline = []
for i in range(30, 0, -1):
date = datetime.now() - timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
# TODO: Replace with actual historical data in future milestone # pylint: disable=fixme
# For now, generate mock data with variation for demonstration purposes
compliance_rate = random.uniform(80, 100) # nosec B311 - Mock data only
timeline.append(TimelinePoint(date=date_str, compliance_rate=round(compliance_rate, 1)))
return DomainReportsResponse(reports=report_entries, compliance_timeline=timeline)
@router.get("/{domain_id}/sources", response_model=DomainSourcesResponse)
async def get_domain_sources(
domain_id: str = Path(..., title="The domain ID or name"),
days: int = Query(30, title="Number of days to look back"),
):
"""
Get sending sources for a specific domain
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_id not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
# Get sending sources for this domain
sources = store.get_domain_sources(domain_id, days=days)
source_entries = []
for source in sources:
source_entries.append(
SourceEntry(
ip=source.get("source_ip", "unknown"),
count=source.get("count", 0),
spf=source.get("spf_result", "unknown"),
dkim=source.get("dkim_result", "unknown"),
dmarc=(
"pass"
if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass"
else "fail"
),
disposition=source.get("disposition", "none"),
)
)
return DomainSourcesResponse(sources=source_entries)
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_domain(domain_id: str = Path(..., title="The domain ID or name")):
"""
Delete a domain and all associated data.
This performs a full cleanup of all reports and records related to this domain.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_id not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
# Perform deletion with cleanup
deleted = store.delete_domain_with_cleanup(domain_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete domain",
)
# Return 204 No Content on success
return None
@router.get("/search", response_model=List[DomainResponse])
async def search_domains(
q: Optional[str] = Query(None, title="Search query for domain name or description"),
policy: Optional[str] = Query(None, title="Filter by DMARC policy"),
page: int = Query(1, title="Page number", ge=1),
limit: int = Query(10, title="Number of domains per page", ge=1, le=100),
):
"""
Search domains with filtering and pagination.
This supports searching by domain name/description and filtering by DMARC policy.
Args:
q: Optional search query for domain name or description
policy: Optional filter by DMARC policy (none, quarantine, reject)
page: Page number (1-based)
limit: Number of domains per page (max 100)
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
# Apply search filter if provided
filtered_domains = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
# Skip domain if it doesn't match the search query
if q and q.lower() not in domain_name.lower():
continue
# Skip domain if it doesn't match the policy filter
if policy and summary.get("policy") != policy:
continue
# Domain passed all filters
filtered_domains.append(
{
"name": domain_name,
"description": "", # No description in in-memory store
"policy": summary.get("policy", "unknown"),
"reports_count": summary.get("reports_processed", 0),
"emails_count": summary.get("total_count", 0),
"compliance_rate": summary.get("compliance_rate", 0.0),
}
)
# Apply pagination
start_idx = (page - 1) * limit
end_idx = start_idx + limit
paginated_domains = filtered_domains[start_idx:end_idx]
return [DomainResponse(**domain) for domain in paginated_domains]