feat(backend): enforce campaign scheduling, add reviewer queue filter, tactic order, duplicate-test counts

- start_execution now blocks a test from starting before its campaign's
  start_date, closing a gap where the field was persisted but never
  enforced
- GET /tests gains a reviewer_id "my reviews" filter for the red_review/
  blue_review lead-gate stage, distinct from pending_validation_side
  (which only ever covered the later in_review stage and ignored
  assignment entirely)
- get_coverage_by_tactic now returns all 14 MITRE tactics in canonical
  kill-chain order instead of an alphabetical, partial list — the
  regular Dashboard and Executive Dashboard both consume this endpoint
  and previously disagreed on order/completeness
- test-template listing now includes existing_test_count per technique
  so the catalog can warn before creating a likely-duplicate test
This commit is contained in:
kitos
2026-07-08 08:46:09 +02:00
parent 21c6febd22
commit 1c8fa436ab
12 changed files with 333 additions and 3 deletions
+34 -1
View File
@@ -38,6 +38,26 @@ from app.schemas.metrics import (
ValidationRate,
)
# Canonical MITRE ATT&CK kill-chain order — used to sort "coverage by
# tactic" consistently everywhere it's rendered (dashboard table, executive
# bar chart), instead of each consumer inventing its own order/subset.
MITRE_TACTIC_ORDER: list[str] = [
"reconnaissance",
"resource-development",
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"impact",
]
# Define function get_coverage_summary
def get_coverage_summary(db: Session) -> CoverageSummary:
@@ -112,6 +132,12 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
lambda: {s.value: 0 for s in TechniqueStatus}
)
# Materialize every canonical tactic up front so the response always
# includes all 14, zero-filled, rather than only whichever tactics
# happen to have techniques seeded.
for tactic in MITRE_TACTIC_ORDER:
tactic_data[tactic] # noqa: B018 — touch to create the defaultdict entry
# Iterate over techniques
for tactic_str, status in techniques:
# Check: not tactic_str
@@ -128,10 +154,17 @@ def get_coverage_by_tactic(db: Session) -> list[TacticCoverage]:
# Assign tactic_data[tactic][status.value] = 1
tactic_data[tactic][status.value] += 1
def _tactic_sort_key(tactic: str) -> tuple[int, str]:
try:
return (MITRE_TACTIC_ORDER.index(tactic), tactic)
except ValueError:
# Anything outside the canonical 14 (e.g. "unknown") sorts last.
return (len(MITRE_TACTIC_ORDER), tactic)
# Assign result = []
result = []
# Iterate over sorted(tactic_data)
for tactic in sorted(tactic_data):
for tactic in sorted(tactic_data, key=_tactic_sort_key):
# Assign counts = tactic_data[tactic]
counts = tactic_data[tactic]
# Assign total = sum(counts.values())
+20
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from typing import Any
# Import Session, joinedload from sqlalchemy.orm
from sqlalchemy import or_
from sqlalchemy.orm import Session, joinedload
# Import from app.domain.errors
@@ -63,6 +64,8 @@ def list_tests(
created_by: uuid.UUID | None = None,
# Entry: pending_validation_side
pending_validation_side: str | None = None,
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
reviewer_id: uuid.UUID | None = None,
not_in_any_campaign: bool = False,
offset: int = 0,
# Entry: limit
@@ -101,6 +104,23 @@ def list_tests(
Test.state == TestState.in_review,
Test.blue_validation_status.in_(["pending", None]),
)
# "My reviews" — tests currently assigned to *reviewer_id* at the
# red_review/blue_review lead-review gate (distinct from
# pending_validation_side, which covers the later in_review stage).
if reviewer_id:
if state == TestState.red_review.value:
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
elif state == TestState.blue_review.value:
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
else:
query = query.filter(
Test.state.in_([TestState.red_review, TestState.blue_review]),
or_(
Test.red_reviewer_assignee == reviewer_id,
Test.blue_reviewer_assignee == reviewer_id,
),
)
if not_in_any_campaign:
linked = db.query(CampaignTest.test_id).distinct().subquery()
query = query.filter(~Test.id.in_(linked))
@@ -16,6 +16,8 @@ from app.domain.errors import EntityNotFoundError
# Import TestTemplate from app.models.test_template
from app.models.test_template import TestTemplate
from app.models.technique import Technique
from app.models.test import Test
# Import escape_like from app.utils
from app.utils import escape_like
@@ -91,6 +93,21 @@ def list_templates(
# Chain .all() call
.all()
)
# Attach existing_test_count per template — lets the catalog warn before
# creating a likely-duplicate test for a technique that already has one.
if templates:
mitre_ids = {t.mitre_technique_id for t in templates}
counts = dict(
db.query(Technique.mitre_id, func.count(Test.id))
.join(Test, Test.technique_id == Technique.id)
.filter(Technique.mitre_id.in_(mitre_ids))
.group_by(Technique.mitre_id)
.all()
)
for t in templates:
t.existing_test_count = counts.get(t.mitre_technique_id, 0)
# Return templates
return templates
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.domain.exceptions import BusinessRuleViolation, InvalidOperationError
from app.domain.test_entity import TestEntity
from app.models.campaign import Campaign, CampaignTest
from app.models.enums import TestState, TeamSide
from app.models.evidence import Evidence
from app.models.test import Test
@@ -174,8 +175,26 @@ def transition_state(
# ---------------------------------------------------------------------------
def _get_campaign_start_date(db: Session, test_id) -> datetime | None:
"""Return the scheduled start_date of the campaign *test_id* belongs to, if any."""
campaign = (
db.query(Campaign)
.join(CampaignTest, CampaignTest.campaign_id == Campaign.id)
.filter(CampaignTest.test_id == test_id)
.first()
)
return campaign.start_date if campaign else None
def start_execution(db: Session, test: Test, user: User) -> Test:
"""Move from ``draft`` → ``red_executing``."""
campaign_start_date = _get_campaign_start_date(db, test.id)
if campaign_start_date and campaign_start_date > datetime.utcnow():
raise BusinessRuleViolation(
"This test's campaign is scheduled to start on "
f"{campaign_start_date.date().isoformat()} — it cannot be started before then."
)
entity = TestEntity.from_orm(test)
# Call entity.start_execution()
entity.start_execution()