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:
@@ -218,6 +218,10 @@ def list_tests(
|
||||
pending_validation_side: Optional[str] = Query(
|
||||
None, description="Filter in_review tests pending validation on 'red' or 'blue' side"
|
||||
),
|
||||
reviewer_id: Optional[uuid.UUID] = Query(
|
||||
None,
|
||||
description="\"My reviews\" queue — filter red_review/blue_review tests assigned to this reviewer",
|
||||
),
|
||||
not_in_any_campaign: bool = Query(
|
||||
False, description="Only return tests not linked to any campaign"
|
||||
),
|
||||
@@ -259,6 +263,7 @@ def list_tests(
|
||||
created_by=created_by,
|
||||
# Keyword argument: pending_validation_side
|
||||
pending_validation_side=pending_validation_side,
|
||||
reviewer_id=reviewer_id,
|
||||
not_in_any_campaign=not_in_any_campaign,
|
||||
offset=offset,
|
||||
# Keyword argument: limit
|
||||
|
||||
@@ -102,6 +102,9 @@ class TestTemplateSummary(BaseModel):
|
||||
severity: str | None = None
|
||||
# Assign is_active = True
|
||||
is_active: bool = True
|
||||
# Number of existing Test rows for this template's technique — lets the
|
||||
# catalog UI warn before creating a likely-duplicate test.
|
||||
existing_test_count: int = 0
|
||||
|
||||
# Assign model_config = ConfigDict(from_attributes=True)
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user