bbe7f49c86
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
- A lead opening a test with a pending procedure suggestion awaiting
their review now gets a blocking popup (approve/reject only) instead
of discovering it later in a separate queue.
- Users can be granted more than one role via extra_roles; only one is
ever active at a time (no permission mixing) and a top-bar switcher
lets the user swap which one, taking effect immediately since role
is read fresh from the DB on every request.
- The password-setup/reset webhook now posts the agreed Power Automate
contract ({to, subject, body} with the platform's standard greeting
and signature template) and supports an admin-configured API key
sent as an x-api-key header.
181 lines
7.2 KiB
Python
181 lines
7.2 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": "expected_detection"}
|
|
_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, source_test_id: uuid.UUID | None = None,
|
|
) -> list[ProcedureSuggestion]:
|
|
"""List pending suggestions, optionally filtered to one team and/or the
|
|
specific test that originated them (used to block a lead's test-detail
|
|
view on a suggestion awaiting their review)."""
|
|
query = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.status == "pending")
|
|
if team is not None:
|
|
query = query.filter(ProcedureSuggestion.team == team)
|
|
if source_test_id is not None:
|
|
query = query.filter(ProcedureSuggestion.source_test_id == source_test_id)
|
|
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
|