refactor(heatmap): extract business logic to dedicated service
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Move layer dispatch, entity-not-found checks, and validation from router to heatmap_service. Router now only validates requests, calls service, and formats responses (no HTTPException, no business logic). Service raises EntityNotFoundError/BusinessRuleViolation instead of returning None. Add build_navigator_export() for centralized dispatch. 29 new tests (253 total, 0 failures).
This commit is contained in:
2026-02-18 16:09:51 +01:00
parent 1338d52cd0
commit e651ef8a8c
3 changed files with 243 additions and 53 deletions

View File

@@ -1,13 +1,15 @@
"""Heatmap endpoints — ATT&CK Navigator-compatible layer generation.
Thin router that delegates to :mod:`app.services.heatmap_service`.
Thin router that delegates entirely to :mod:`app.services.heatmap_service`.
No business logic lives here — only request validation and response
formatting.
"""
import io
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
@@ -19,9 +21,6 @@ from app.services import heatmap_service
router = APIRouter(prefix="/heatmap", tags=["heatmap"])
# ── GET /heatmap/coverage ─────────────────────────────────────────────
@router.get("/coverage")
def heatmap_coverage(
platforms: Optional[str] = Query(None, description="Comma-separated platforms"),
@@ -36,9 +35,6 @@ def heatmap_coverage(
)
# ── GET /heatmap/threat-actor/{actor_id} ──────────────────────────────
@router.get("/threat-actor/{actor_id}")
def heatmap_threat_actor(
actor_id: str,
@@ -49,15 +45,9 @@ def heatmap_threat_actor(
current_user: User = Depends(get_current_user),
):
"""Threat actor layer — techniques used by an actor with coverage color."""
layer = heatmap_service.build_threat_actor_layer(
return heatmap_service.build_threat_actor_layer(
db, actor_id, platforms=platforms, tactics=tactics, min_score=min_score,
)
if layer is None:
raise HTTPException(status_code=404, detail="Threat actor not found")
return layer
# ── GET /heatmap/detection-rules ──────────────────────────────────────
@router.get("/detection-rules")
@@ -74,9 +64,6 @@ def heatmap_detection_rules(
)
# ── GET /heatmap/campaign/{campaign_id} ───────────────────────────────
@router.get("/campaign/{campaign_id}")
def heatmap_campaign(
campaign_id: str,
@@ -87,25 +74,9 @@ def heatmap_campaign(
current_user: User = Depends(get_current_user),
):
"""Campaign layer — only techniques in the campaign, colored by test state."""
layer = heatmap_service.build_campaign_layer(
return heatmap_service.build_campaign_layer(
db, campaign_id, platforms=platforms, tactics=tactics, min_score=min_score,
)
if layer is None:
raise HTTPException(status_code=404, detail="Campaign not found")
return layer
# ── GET /heatmap/export-navigator ─────────────────────────────────────
_LAYER_BUILDERS = {
"coverage": lambda db, **kw: heatmap_service.build_coverage_layer(db, **kw),
"detection-rules": lambda db, **kw: heatmap_service.build_detection_rules_layer(db, **kw),
}
_LAYER_BUILDERS_WITH_ID = {
"threat-actor": lambda db, lid, **kw: heatmap_service.build_threat_actor_layer(db, lid, **kw),
"campaign": lambda db, lid, **kw: heatmap_service.build_campaign_layer(db, lid, **kw),
}
@router.get("/export-navigator")
@@ -119,18 +90,10 @@ def export_navigator(
current_user: User = Depends(get_current_user),
):
"""Export a heatmap layer as a downloadable JSON file for ATT&CK Navigator."""
kwargs = dict(platforms=platforms, tactics=tactics, min_score=min_score)
if layer in _LAYER_BUILDERS:
data = _LAYER_BUILDERS[layer](db, **kwargs)
elif layer in _LAYER_BUILDERS_WITH_ID:
if not layer_id:
raise HTTPException(status_code=400, detail=f"layer_id required for {layer} layer")
data = _LAYER_BUILDERS_WITH_ID[layer](db, layer_id, **kwargs)
if data is None:
raise HTTPException(status_code=404, detail=f"{layer} not found")
else:
raise HTTPException(status_code=400, detail=f"Unknown layer type: {layer}")
data = heatmap_service.build_navigator_export(
db, layer, layer_id=layer_id,
platforms=platforms, tactics=tactics, min_score=min_score,
)
json_content = json.dumps(data, indent=2, default=str)
buffer = io.BytesIO(json_content.encode("utf-8"))