Files
Aegis/backend/app/services/procedure_suggestion_service.py
T
kitos 8fcd733d4a
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
fix(tests): merge approved procedure suggestions instead of overwriting
Approving a suggestion was replacing the template's whole procedure
field with just that submission's extracted text, silently dropping
whatever an earlier approved round had already added. Now it appends
only the genuinely new lines, so templates accumulate commands across
rounds instead of losing them.
2026-07-14 16:25:24 +02:00

175 lines
6.9 KiB
Python

"""Procedure-improvement suggestion workflow.
When an operator submits a round with a filled-in procedure field, the
command(s) in it are extracted heuristically (see
``procedure_extraction_service``) and proposed as an update to the
originating template's suggested-procedure field. A suggestion is only
ever created when there's a real, novel improvement to propose — nothing
is written to the template until a lead reviews and approves it, and
approving one only ever appends new commands, never overwrites or drops
whatever earlier approved rounds already contributed.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy.orm import Session
from app.domain.errors import EntityNotFoundError, InvalidOperationError
from app.models.procedure_suggestion import ProcedureSuggestion
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.user import User
from app.services.notification_service import notify_role
from app.services.procedure_extraction_service import extract_commands
# Which Test field holds the operator's free-text procedure, and which
# TestTemplate field it's proposed as an improvement to, per team.
_SOURCE_FIELD = {"red": "procedure_text", "blue": "detect_procedure"}
_TEMPLATE_FIELD = {"red": "attack_procedure", "blue": "detect_suggested_procedure"}
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
def _new_lines(current: str | None, suggested: str) -> list[str]:
"""Lines in *suggested* not already present verbatim in *current*.
Line-level, not whole-text: a later round's extraction is usually a
different subset/superset of commands than an earlier one, so an exact
string comparison would treat almost every resubmission as "novel" and
then, on approval, blow away whatever an earlier approved round already
contributed.
"""
existing = {line.strip() for line in (current or "").splitlines() if line.strip()}
seen: set[str] = set()
new: list[str] = []
for line in suggested.splitlines():
stripped = line.strip()
if not stripped or stripped in existing or stripped in seen:
continue
new.append(stripped)
seen.add(stripped)
return new
def _merge_procedure_text(current: str | None, suggested: str) -> str:
"""Append genuinely new lines from *suggested* onto *current*.
Approving a suggestion must only ever grow a template's procedure,
never replace it — otherwise a narrower extraction from a later round
would silently erase commands an earlier approved round already added.
"""
current_stripped = (current or "").strip()
new = _new_lines(current, suggested)
if not new:
return current_stripped
if not current_stripped:
return "\n".join(new)
return current_stripped + "\n" + "\n".join(new)
def create_suggestion_from_submission(db: Session, test: Test, team: str) -> ProcedureSuggestion | None:
"""Propose a template procedure improvement from *test*'s just-submitted round.
Returns the created (unflushed-to-caller-commit) suggestion, or ``None``
when there's nothing worth proposing: the test wasn't created from a
template, no command could be extracted, the extraction matches what
the template already suggests, or an identical suggestion is already
pending review. Does not commit; caller uses UnitOfWork.
"""
if not test.source_template_id:
return None
procedure_text = getattr(test, _SOURCE_FIELD[team])
extracted = extract_commands(procedure_text)
if not extracted:
return None
template = db.query(TestTemplate).filter(TestTemplate.id == test.source_template_id).first()
if template is None:
return None
current = getattr(template, _TEMPLATE_FIELD[team])
if not _new_lines(current, extracted):
return None
duplicate = (
db.query(ProcedureSuggestion)
.filter(
ProcedureSuggestion.template_id == template.id,
ProcedureSuggestion.team == team,
ProcedureSuggestion.suggested_text == extracted,
ProcedureSuggestion.status == "pending",
)
.first()
)
if duplicate is not None:
return None
submitter_id = test.red_tech_assignee if team == "red" else test.blue_tech_assignee
suggestion = ProcedureSuggestion(
template_id=template.id,
team=team,
suggested_text=extracted,
source_test_id=test.id,
submitted_by=submitter_id,
)
db.add(suggestion)
db.flush()
notify_role(
db,
role=_LEAD_ROLE[team],
type="procedure_suggestion",
title="New procedure suggestion for review",
message=f'A {team} procedure improvement was proposed for template "{template.name}".',
entity_type="procedure_suggestion",
entity_id=suggestion.id,
)
return suggestion
def list_pending_suggestions(db: Session, *, team: str | None = None) -> list[ProcedureSuggestion]:
"""List pending suggestions, optionally filtered to one team."""
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
if team is not None:
query = query.filter(ProcedureSuggestion.team == team)
return query.order_by(ProcedureSuggestion.created_at.asc()).all()
def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> ProcedureSuggestion:
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
if suggestion is None:
raise EntityNotFoundError("ProcedureSuggestion", str(suggestion_id))
if suggestion.status != "pending":
raise InvalidOperationError("This suggestion has already been reviewed.")
return suggestion
def approve_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Approve a suggestion: write it into the template and mark reviewed. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
if template is None:
raise EntityNotFoundError("TestTemplate", str(suggestion.template_id))
current_text = getattr(template, _TEMPLATE_FIELD[suggestion.team])
setattr(template, _TEMPLATE_FIELD[suggestion.team], _merge_procedure_text(current_text, suggestion.suggested_text))
suggestion.status = "approved"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion
def reject_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> ProcedureSuggestion:
"""Reject a suggestion without touching the template. Does not commit."""
suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
suggestion.status = "rejected"
suggestion.reviewed_by = user.id
suggestion.reviewed_at = datetime.utcnow()
db.flush()
return suggestion