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
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:
@@ -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")
|
||||
Reference in New Issue
Block a user