60f9464ec5
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
Two overlapping TestTemplate fields (expected_detection: narrative guidance from imports/leads; detect_suggested_procedure: concrete commands from approved suggestions) are now one. Blue-side procedure suggestions target expected_detection directly, appending onto whatever is already there rather than overwriting it — same merge-not-overwrite behavior already used for the red side. Existing detect_suggested_procedure data is folded into expected_detection before the column is dropped.
140 lines
5.9 KiB
Python
140 lines
5.9 KiB
Python
"""Router for procedure-improvement suggestion review.
|
|
|
|
Endpoints
|
|
---------
|
|
GET /procedure-suggestions — list pending suggestions (lead/admin)
|
|
POST /procedure-suggestions/{id}/approve — write suggestion into template (lead/admin)
|
|
POST /procedure-suggestions/{id}/reject — dismiss suggestion (lead/admin)
|
|
|
|
A red_lead only ever sees/acts on "red" team suggestions, a blue_lead only
|
|
"blue" — admins can see and act on both.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from app.database import get_db
|
|
from app.dependencies.auth import require_any_role_strict
|
|
from app.domain.errors import EntityNotFoundError, InvalidOperationError
|
|
from app.domain.unit_of_work import UnitOfWork
|
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
|
from app.models.test_template import TestTemplate
|
|
from app.models.user import User
|
|
from app.schemas.procedure_suggestion import ProcedureSuggestionOut
|
|
from app.services.audit_service import log_action
|
|
from app.services.procedure_suggestion_service import (
|
|
approve_suggestion as approve_suggestion_svc,
|
|
)
|
|
from app.services.procedure_suggestion_service import (
|
|
list_pending_suggestions,
|
|
)
|
|
from app.services.procedure_suggestion_service import (
|
|
reject_suggestion as reject_suggestion_svc,
|
|
)
|
|
from sqlalchemy.orm import Session
|
|
|
|
router = APIRouter(prefix="/procedure-suggestions", tags=["procedure-suggestions"])
|
|
|
|
|
|
def _team_for_role(user: User) -> str | None:
|
|
"""Return the single team a non-admin lead is scoped to, or None for admin (both)."""
|
|
if user.role == "red_lead":
|
|
return "red"
|
|
if user.role == "blue_lead":
|
|
return "blue"
|
|
return None
|
|
|
|
|
|
def _to_out(suggestion, template_by_id: dict) -> ProcedureSuggestionOut:
|
|
out = ProcedureSuggestionOut.model_validate(suggestion)
|
|
template = template_by_id.get(suggestion.template_id)
|
|
if template is not None:
|
|
out.template_name = template.name
|
|
out.template_current_text = (
|
|
template.attack_procedure if suggestion.team == "red" else template.expected_detection
|
|
)
|
|
return out
|
|
|
|
|
|
@router.get("", response_model=list[ProcedureSuggestionOut])
|
|
def list_suggestions(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
) -> list[ProcedureSuggestionOut]:
|
|
"""List pending procedure suggestions, scoped to the caller's team (admins see both)."""
|
|
team = _team_for_role(current_user)
|
|
suggestions = list_pending_suggestions(db, team=team)
|
|
template_ids = {s.template_id for s in suggestions}
|
|
templates = (
|
|
db.query(TestTemplate).filter(TestTemplate.id.in_(template_ids)).all() if template_ids else []
|
|
)
|
|
template_by_id = {t.id: t for t in templates}
|
|
return [_to_out(s, template_by_id) for s in suggestions]
|
|
|
|
|
|
@router.post("/{suggestion_id}/approve", response_model=ProcedureSuggestionOut)
|
|
def approve_suggestion(
|
|
suggestion_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
) -> ProcedureSuggestionOut:
|
|
"""Approve a suggestion, writing it into its template's suggested-procedure field."""
|
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
|
try:
|
|
with UnitOfWork(db) as uow:
|
|
suggestion = approve_suggestion_svc(db, suggestion_id, current_user)
|
|
log_action(
|
|
db, user_id=current_user.id, action="approve_procedure_suggestion",
|
|
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
|
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
|
)
|
|
uow.commit()
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
except InvalidOperationError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
db.refresh(suggestion)
|
|
template = db.query(TestTemplate).filter(TestTemplate.id == suggestion.template_id).first()
|
|
return _to_out(suggestion, {template.id: template} if template else {})
|
|
|
|
|
|
@router.post("/{suggestion_id}/reject", response_model=ProcedureSuggestionOut)
|
|
def reject_suggestion(
|
|
suggestion_id: uuid.UUID,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")),
|
|
) -> ProcedureSuggestionOut:
|
|
"""Reject a suggestion without touching the template."""
|
|
_check_team_permission(current_user, _team_or_404(db, suggestion_id))
|
|
try:
|
|
with UnitOfWork(db) as uow:
|
|
suggestion = reject_suggestion_svc(db, suggestion_id, current_user)
|
|
log_action(
|
|
db, user_id=current_user.id, action="reject_procedure_suggestion",
|
|
entity_type="procedure_suggestion", entity_id=suggestion.id,
|
|
details={"template_id": str(suggestion.template_id), "team": suggestion.team},
|
|
)
|
|
uow.commit()
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
except InvalidOperationError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
db.refresh(suggestion)
|
|
return _to_out(suggestion, {})
|
|
|
|
|
|
def _team_or_404(db: Session, suggestion_id: uuid.UUID) -> str:
|
|
"""Look up just the team of a suggestion, for a permission check before mutating it."""
|
|
suggestion = db.query(ProcedureSuggestion).filter(ProcedureSuggestion.id == suggestion_id).first()
|
|
if suggestion is None:
|
|
raise HTTPException(status_code=404, detail="Procedure suggestion not found")
|
|
return suggestion.team
|
|
|
|
|
|
def _check_team_permission(user: User, team: str) -> None:
|
|
"""Raise 403 if a non-admin lead tries to act on the other team's suggestion."""
|
|
scoped_team = _team_for_role(user)
|
|
if scoped_team is not None and scoped_team != team:
|
|
raise HTTPException(status_code=403, detail=f"Not authorized to review {team} team suggestions")
|