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
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.
175 lines
6.9 KiB
Python
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
|