"""Router for operator template-proposal review. Endpoints --------- POST /template-suggestions — propose a new template (red_tech/blue_tech) GET /template-suggestions — list pending suggestions (lead/admin) POST /template-suggestions/{id}/approve — create the template, with optional edits (lead/admin) POST /template-suggestions/{id}/reject — discard the 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 sqlalchemy.orm import Session 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.technique import Technique from app.models.template_suggestion import TemplateSuggestion from app.models.user import User from app.schemas.template_suggestion import ( TemplateSuggestionApprove, TemplateSuggestionCreate, TemplateSuggestionOut, ) from app.services.audit_service import log_action from app.services.template_suggestion_service import ( approve_template_suggestion as approve_suggestion_svc, ) from app.services.template_suggestion_service import ( create_template_suggestion as create_suggestion_svc, ) from app.services.template_suggestion_service import ( list_pending_template_suggestions, ) from app.services.template_suggestion_service import ( reject_template_suggestion as reject_suggestion_svc, ) router = APIRouter(prefix="/template-suggestions", tags=["template-suggestions"]) def _team_for_lead(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: TemplateSuggestion) -> TemplateSuggestionOut: out = TemplateSuggestionOut.model_validate(suggestion) if suggestion.submitter: out.submitter_name = suggestion.submitter.username or suggestion.submitter.email return out @router.post("", response_model=TemplateSuggestionOut, status_code=201) def propose_template( payload: TemplateSuggestionCreate, db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_tech", "blue_tech")), ) -> TemplateSuggestionOut: """Propose a new test template. Lands in the review queue, not the live catalog.""" with UnitOfWork(db) as uow: suggestion = create_suggestion_svc(db, current_user, **payload.model_dump()) log_action( db, user_id=current_user.id, action="propose_test_template", entity_type="template_suggestion", entity_id=suggestion.id, details={"name": suggestion.name, "mitre_technique_id": suggestion.mitre_technique_id}, ) uow.commit() db.refresh(suggestion) return _to_out(suggestion) @router.get("", response_model=list[TemplateSuggestionOut]) def list_suggestions( db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> list[TemplateSuggestionOut]: """List pending template suggestions, scoped to the caller's team (admins see both).""" team = _team_for_lead(current_user) suggestions = list_pending_template_suggestions(db, team=team) return [_to_out(s) for s in suggestions] @router.post("/{suggestion_id}/approve", response_model=TemplateSuggestionOut) def approve_suggestion( suggestion_id: uuid.UUID, payload: TemplateSuggestionApprove | None = None, db: Session = Depends(get_db), current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "admin")), ) -> TemplateSuggestionOut: """Approve a suggestion, optionally editing fields, creating the real template.""" _check_team_permission(current_user, _team_or_404(db, suggestion_id)) overrides = payload.model_dump(exclude_unset=True) if payload else {} try: with UnitOfWork(db) as uow: suggestion, template = approve_suggestion_svc(db, suggestion_id, current_user, overrides) if template.mitre_technique_id: technique = ( db.query(Technique) .filter(Technique.mitre_id == template.mitre_technique_id) .first() ) if technique: technique.review_required = True log_action( db, user_id=current_user.id, action="approve_template_suggestion", entity_type="template_suggestion", entity_id=suggestion.id, details={"created_template_id": str(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) @router.post("/{suggestion_id}/reject", response_model=TemplateSuggestionOut) 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")), ) -> TemplateSuggestionOut: """Discard a suggestion without creating a 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_template_suggestion", entity_type="template_suggestion", entity_id=suggestion.id, details={"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(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first() if suggestion is None: raise HTTPException(status_code=404, detail="Template 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_lead(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")