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"))

View File

@@ -15,6 +15,7 @@ from typing import Optional
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
from app.models.campaign import Campaign, CampaignTest
from app.models.detection_rule import DetectionRule
from app.models.defensive_technique import DefensiveTechniqueMapping
@@ -206,14 +207,14 @@ def build_threat_actor_layer(
platforms: str | None = None,
tactics: str | None = None,
min_score: int = 0,
) -> dict | None:
) -> dict:
"""Threat actor layer -- techniques used by an actor with coverage colour.
Returns ``None`` if the actor does not exist.
Raises :class:`EntityNotFoundError` if the actor does not exist.
"""
actor = db.query(ThreatActor).filter(ThreatActor.id == actor_id).first()
if not actor:
return None
raise EntityNotFoundError("ThreatActor", actor_id)
layer = _build_layer_skeleton(
f"Threat Actor: {actor.name}",
@@ -364,14 +365,14 @@ def build_campaign_layer(
platforms: str | None = None,
tactics: str | None = None,
min_score: int = 0,
) -> dict | None:
) -> dict:
"""Campaign layer -- techniques in a campaign, coloured by test state.
Returns ``None`` if the campaign does not exist.
Raises :class:`EntityNotFoundError` if the campaign does not exist.
"""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
return None
raise EntityNotFoundError("Campaign", campaign_id)
layer = _build_layer_skeleton(
f"Campaign: {campaign.name}",
@@ -450,3 +451,49 @@ def build_campaign_layer(
})
return layer
# ── Layer dispatch (for Navigator export) ────────────────────────────
_LAYER_BUILDERS = {
"coverage": lambda db, **kw: build_coverage_layer(db, **kw),
"detection-rules": lambda db, **kw: build_detection_rules_layer(db, **kw),
}
_LAYER_BUILDERS_WITH_ID = {
"threat-actor": lambda db, lid, **kw: build_threat_actor_layer(db, lid, **kw),
"campaign": lambda db, lid, **kw: build_campaign_layer(db, lid, **kw),
}
SUPPORTED_LAYER_TYPES = set(_LAYER_BUILDERS) | set(_LAYER_BUILDERS_WITH_ID)
def build_navigator_export(
db: Session,
layer_type: str,
*,
layer_id: str | None = None,
platforms: str | None = None,
tactics: str | None = None,
min_score: int = 0,
) -> dict:
"""Build a heatmap layer dict by type name.
Raises :class:`BusinessRuleViolation` for unknown layer types or
missing ``layer_id``. Raises :class:`EntityNotFoundError` when
an entity-bound layer (threat-actor, campaign) references a
non-existent record.
"""
kwargs = dict(platforms=platforms, tactics=tactics, min_score=min_score)
if layer_type in _LAYER_BUILDERS:
return _LAYER_BUILDERS[layer_type](db, **kwargs)
if layer_type in _LAYER_BUILDERS_WITH_ID:
if not layer_id:
raise BusinessRuleViolation(
f"layer_id is required for '{layer_type}' layer"
)
return _LAYER_BUILDERS_WITH_ID[layer_type](db, layer_id, **kwargs)
raise BusinessRuleViolation(f"Unknown layer type: {layer_type}")