c753797019
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.
167 lines
6.9 KiB
Python
167 lines
6.9 KiB
Python
"""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")
|