feat(test-catalog): add template creation and lead-approval workflow
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

Leads get a Create Template button that adds directly to the catalog
(existing POST /test-templates). Operators (red_tech/blue_tech) get the
same button, but their proposal now lands in a new template_suggestions
review queue instead — a lead on their team can approve it as-is, edit
fields before approving, or discard it. Modeled on the existing
ProcedureSuggestion review workflow.
This commit is contained in:
kitos
2026-07-16 12:08:23 +02:00
parent cf4a6c3cde
commit c753797019
12 changed files with 1009 additions and 4 deletions
+166
View File
@@ -0,0 +1,166 @@
"""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")