feat(domain): add domain layer foundation -- enums, value objects, TechniqueEntity, repository ports
This commit is contained in:
159
backend/app/domain/entities/technique.py
Normal file
159
backend/app/domain/entities/technique.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""TechniqueEntity — pure domain object for a MITRE ATT&CK technique.
|
||||
|
||||
Owns the status recalculation logic that was previously in
|
||||
``status_service.py``. Has **no** dependency on FastAPI, SQLAlchemy,
|
||||
or any infrastructure concern.
|
||||
|
||||
Usage::
|
||||
|
||||
entity = TechniqueEntity.from_orm(technique_orm_model)
|
||||
entity.recalculate_status(test_states_and_results)
|
||||
entity.mark_reviewed()
|
||||
entity.apply_to(technique_orm_model)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.domain.enums import TechniqueStatus, TestResult, TestState
|
||||
from app.domain.value_objects.mitre_id import MitreId
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TestSnapshot:
|
||||
"""Minimal read-only view of a test for status calculation."""
|
||||
|
||||
state: TestState
|
||||
detection_result: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TechniqueEntity:
|
||||
"""Pure domain representation of a MITRE ATT&CK technique."""
|
||||
|
||||
id: uuid.UUID
|
||||
mitre_id: str
|
||||
name: str
|
||||
tactic: str | None = None
|
||||
description: str | None = None
|
||||
platforms: list[str] = field(default_factory=list)
|
||||
is_subtechnique: bool = False
|
||||
parent_mitre_id: str | None = None
|
||||
status_global: TechniqueStatus = TechniqueStatus.not_evaluated
|
||||
review_required: bool = False
|
||||
last_review_date: datetime | None = None
|
||||
|
||||
# -- Factory -----------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
mitre_id: str,
|
||||
name: str,
|
||||
tactic: str | None = None,
|
||||
description: str | None = None,
|
||||
platforms: list[str] | None = None,
|
||||
) -> TechniqueEntity:
|
||||
"""Create a new technique, validating the MITRE ID format."""
|
||||
validated_id = MitreId(mitre_id)
|
||||
return cls(
|
||||
id=uuid.uuid4(),
|
||||
mitre_id=validated_id.value,
|
||||
name=name,
|
||||
tactic=tactic,
|
||||
description=description,
|
||||
platforms=platforms or [],
|
||||
is_subtechnique=validated_id.is_subtechnique,
|
||||
parent_mitre_id=validated_id.parent_id,
|
||||
status_global=TechniqueStatus.not_evaluated,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, model: Any) -> TechniqueEntity:
|
||||
"""Build a TechniqueEntity from a SQLAlchemy Technique model."""
|
||||
raw_status = model.status_global
|
||||
status = (
|
||||
raw_status
|
||||
if isinstance(raw_status, TechniqueStatus)
|
||||
else TechniqueStatus(raw_status)
|
||||
)
|
||||
return cls(
|
||||
id=model.id,
|
||||
mitre_id=model.mitre_id,
|
||||
name=model.name,
|
||||
tactic=model.tactic,
|
||||
description=model.description,
|
||||
platforms=model.platforms or [],
|
||||
is_subtechnique=model.is_subtechnique or False,
|
||||
parent_mitre_id=model.parent_mitre_id,
|
||||
status_global=status,
|
||||
review_required=model.review_required or False,
|
||||
last_review_date=model.last_review_date,
|
||||
)
|
||||
|
||||
def apply_to(self, model: Any) -> None:
|
||||
"""Copy mutable fields back onto the ORM model."""
|
||||
model.status_global = self.status_global
|
||||
model.review_required = self.review_required
|
||||
model.last_review_date = self.last_review_date
|
||||
|
||||
# -- Business logic ----------------------------------------------------
|
||||
|
||||
def recalculate_status(
|
||||
self,
|
||||
test_snapshots: list[tuple[str, str | None]],
|
||||
) -> TechniqueStatus:
|
||||
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
|
||||
|
||||
Rules (v2):
|
||||
1. No tests -> not_evaluated
|
||||
2. All validated -> inspect detection results:
|
||||
- All detected -> validated
|
||||
- Any partially_detected -> partial
|
||||
- Otherwise -> not_covered
|
||||
3. Some validated, others in progress -> partial
|
||||
4. All in intermediate states -> in_progress
|
||||
|
||||
Returns the new status (also set on the entity).
|
||||
"""
|
||||
tests = [
|
||||
_TestSnapshot(
|
||||
state=s if isinstance(s, TestState) else TestState(s),
|
||||
detection_result=dr,
|
||||
)
|
||||
for s, dr in test_snapshots
|
||||
]
|
||||
|
||||
if not tests:
|
||||
self.status_global = TechniqueStatus.not_evaluated
|
||||
elif all(t.state == TestState.validated for t in tests):
|
||||
results = [t.detection_result for t in tests if t.detection_result]
|
||||
if results and all(r == TestResult.detected or r == "detected" for r in results):
|
||||
self.status_global = TechniqueStatus.validated
|
||||
elif any(
|
||||
r == TestResult.partially_detected or r == "partially_detected"
|
||||
for r in results
|
||||
):
|
||||
self.status_global = TechniqueStatus.partial
|
||||
else:
|
||||
self.status_global = TechniqueStatus.not_covered
|
||||
elif any(t.state == TestState.validated for t in tests):
|
||||
self.status_global = TechniqueStatus.partial
|
||||
else:
|
||||
self.status_global = TechniqueStatus.in_progress
|
||||
|
||||
return self.status_global
|
||||
|
||||
def mark_reviewed(self) -> None:
|
||||
"""Mark the technique as reviewed, clearing the review flag."""
|
||||
self.review_required = False
|
||||
self.last_review_date = datetime.utcnow()
|
||||
|
||||
def flag_for_review(self) -> None:
|
||||
"""Flag the technique as needing review."""
|
||||
self.review_required = True
|
||||
Reference in New Issue
Block a user