From c75379701901cadbc7f70b9ea083b5a46fc93650 Mon Sep 17 00:00:00 2001
From: kitos
Date: Thu, 16 Jul 2026 12:08:23 +0200
Subject: [PATCH] feat(test-catalog): add template creation and lead-approval
workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../versions/b064_template_suggestions.py | 54 +++++
backend/app/main.py | 2 +
backend/app/models/__init__.py | 2 +
backend/app/models/template_suggestion.py | 53 +++++
backend/app/routers/template_suggestions.py | 166 +++++++++++++++
backend/app/schemas/template_suggestion.py | 72 +++++++
.../services/template_suggestion_service.py | 118 +++++++++++
backend/tests/test_template_suggestions.py | 145 +++++++++++++
frontend/src/api/template-suggestions.ts | 38 ++++
.../src/components/CreateTemplateModal.tsx | 197 ++++++++++++++++++
frontend/src/pages/TestCatalogPage.tsx | 140 ++++++++++++-
frontend/src/types/models.ts | 26 +++
12 files changed, 1009 insertions(+), 4 deletions(-)
create mode 100644 backend/alembic/versions/b064_template_suggestions.py
create mode 100644 backend/app/models/template_suggestion.py
create mode 100644 backend/app/routers/template_suggestions.py
create mode 100644 backend/app/schemas/template_suggestion.py
create mode 100644 backend/app/services/template_suggestion_service.py
create mode 100644 backend/tests/test_template_suggestions.py
create mode 100644 frontend/src/api/template-suggestions.ts
create mode 100644 frontend/src/components/CreateTemplateModal.tsx
diff --git a/backend/alembic/versions/b064_template_suggestions.py b/backend/alembic/versions/b064_template_suggestions.py
new file mode 100644
index 0000000..3c429b6
--- /dev/null
+++ b/backend/alembic/versions/b064_template_suggestions.py
@@ -0,0 +1,54 @@
+"""Add template_suggestions table for operator template proposals.
+
+Leads can already create a TestTemplate directly (POST /test-templates).
+Operators (red_tech/blue_tech) get the same "create template" action, but
+their proposal lands here pending a lead's review instead of the live
+catalog.
+
+Revision ID: b064
+Revises: b063
+Create Date: 2026-07-16
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision = "b064"
+down_revision = "b063"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "template_suggestions",
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
+ sa.Column("mitre_technique_id", sa.String(), nullable=False),
+ sa.Column("name", sa.String(), nullable=False),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("source", sa.String(), nullable=False, server_default="custom"),
+ sa.Column("source_url", sa.String(), nullable=True),
+ sa.Column("attack_procedure", sa.Text(), nullable=True),
+ sa.Column("expected_detection", sa.Text(), nullable=True),
+ sa.Column("platform", sa.String(), nullable=True),
+ sa.Column("tool_suggested", sa.String(), nullable=True),
+ sa.Column("severity", sa.String(), nullable=True),
+ sa.Column("atomic_test_id", sa.String(), nullable=True),
+ sa.Column("suggested_remediation", sa.Text(), nullable=True),
+ sa.Column("team", sa.String(10), nullable=False),
+ sa.Column("submitted_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("status", sa.String(10), nullable=False, server_default="pending"),
+ sa.Column("reviewed_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
+ sa.Column("reviewed_at", sa.DateTime(), nullable=True),
+ sa.Column("created_template_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("test_templates.id"), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+ op.create_index("ix_template_suggestions_status", "template_suggestions", ["status"])
+ op.create_index("ix_template_suggestions_team", "template_suggestions", ["team"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_template_suggestions_team", table_name="template_suggestions")
+ op.drop_index("ix_template_suggestions_status", table_name="template_suggestions")
+ op.drop_table("template_suggestions")
diff --git a/backend/app/main.py b/backend/app/main.py
index 1358df2..be46ebc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -44,6 +44,7 @@ from app.routers import tests as tests_router
from app.routers import evidence as evidence_router
from app.routers import test_templates as test_templates_router
from app.routers import procedure_suggestions as procedure_suggestions_router
+from app.routers import template_suggestions as template_suggestions_router
from app.routers import system as system_router
from app.routers import metrics as metrics_router
from app.routers import users as users_router
@@ -215,6 +216,7 @@ app.include_router(evidence_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(test_templates_router.router, prefix="/api/v1")
app.include_router(procedure_suggestions_router.router, prefix="/api/v1")
+app.include_router(template_suggestions_router.router, prefix="/api/v1")
# Call app.include_router()
app.include_router(system_router.router, prefix="/api/v1")
# Call app.include_router()
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 00c41db..a2d99d2 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -46,6 +46,7 @@ from app.models.test import Test
from app.models.test_round_history import TestRoundHistory
from app.models.test_template import TestTemplate
from app.models.procedure_suggestion import ProcedureSuggestion
+from app.models.template_suggestion import TemplateSuggestion
from app.models.user import User
# Assign __all__ = [
@@ -90,4 +91,5 @@ __all__ = [
"AlertInstance",
"TestRoundHistory",
"ProcedureSuggestion",
+ "TemplateSuggestion",
]
diff --git a/backend/app/models/template_suggestion.py b/backend/app/models/template_suggestion.py
new file mode 100644
index 0000000..98fbce5
--- /dev/null
+++ b/backend/app/models/template_suggestion.py
@@ -0,0 +1,53 @@
+"""SQLAlchemy model for operator-proposed test templates, pending lead review.
+
+Leads create templates directly (see TestTemplateCreate router). An
+operator (red_tech/blue_tech) gets the same "create template" action, but
+their submission lands here instead of the live catalog — a lead on their
+team reviews it, optionally edits any field, and either approves it (which
+creates the real TestTemplate) or discards it.
+"""
+
+import uuid
+
+from sqlalchemy import Column, DateTime, ForeignKey, String, Text, func
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import relationship
+
+from app.database import Base
+
+
+class TemplateSuggestion(Base):
+ """A proposed new TestTemplate submitted by an operator, pending lead review."""
+
+ __tablename__ = "template_suggestions"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+
+ # Proposed TestTemplate fields — mirrors TestTemplateCreate.
+ mitre_technique_id = Column(String, nullable=False)
+ name = Column(String, nullable=False)
+ description = Column(Text, nullable=True)
+ source = Column(String, nullable=False, default="custom", server_default="custom")
+ source_url = Column(String, nullable=True)
+ attack_procedure = Column(Text, nullable=True)
+ expected_detection = Column(Text, nullable=True)
+ platform = Column(String, nullable=True)
+ tool_suggested = Column(String, nullable=True)
+ severity = Column(String, nullable=True)
+ atomic_test_id = Column(String, nullable=True)
+ suggested_remediation = Column(Text, nullable=True)
+
+ # "red" or "blue" — derived from the submitter's role, determines which
+ # lead reviews it (admins can review both).
+ team = Column(String(10), nullable=False)
+ submitted_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+
+ status = Column(String(10), nullable=False, default="pending", server_default="pending") # pending/approved/rejected
+ reviewed_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+ reviewed_at = Column(DateTime, nullable=True)
+ created_template_id = Column(UUID(as_uuid=True), ForeignKey("test_templates.id"), nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ submitter = relationship("User", foreign_keys=[submitted_by])
+ reviewer = relationship("User", foreign_keys=[reviewed_by])
+ created_template = relationship("TestTemplate", foreign_keys=[created_template_id])
diff --git a/backend/app/routers/template_suggestions.py b/backend/app/routers/template_suggestions.py
new file mode 100644
index 0000000..e0631ee
--- /dev/null
+++ b/backend/app/routers/template_suggestions.py
@@ -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")
diff --git a/backend/app/schemas/template_suggestion.py b/backend/app/schemas/template_suggestion.py
new file mode 100644
index 0000000..7872879
--- /dev/null
+++ b/backend/app/schemas/template_suggestion.py
@@ -0,0 +1,72 @@
+"""Pydantic schemas for the TemplateSuggestion (operator template proposal) workflow."""
+
+import uuid
+from datetime import datetime
+
+from pydantic import BaseModel, ConfigDict
+
+
+class TemplateSuggestionCreate(BaseModel):
+ """Payload for an operator proposing a new test template."""
+
+ mitre_technique_id: str
+ name: str
+ description: str | None = None
+ source: str = "custom"
+ source_url: str | None = None
+ attack_procedure: str | None = None
+ expected_detection: str | None = None
+ platform: str | None = None
+ tool_suggested: str | None = None
+ severity: str | None = None
+ atomic_test_id: str | None = None
+ suggested_remediation: str | None = None
+
+
+class TemplateSuggestionApprove(BaseModel):
+ """Optional field overrides a lead can apply while approving a suggestion.
+
+ Any field left unset falls back to what the operator originally
+ submitted — the lead only needs to pass the fields they want to change.
+ """
+
+ mitre_technique_id: str | None = None
+ name: str | None = None
+ description: str | None = None
+ source: str | None = None
+ source_url: str | None = None
+ attack_procedure: str | None = None
+ expected_detection: str | None = None
+ platform: str | None = None
+ tool_suggested: str | None = None
+ severity: str | None = None
+ atomic_test_id: str | None = None
+ suggested_remediation: str | None = None
+
+
+class TemplateSuggestionOut(BaseModel):
+ """Full representation of a pending/reviewed template suggestion."""
+
+ id: uuid.UUID
+ mitre_technique_id: str
+ name: str
+ description: str | None = None
+ source: str
+ source_url: str | None = None
+ attack_procedure: str | None = None
+ expected_detection: str | None = None
+ platform: str | None = None
+ tool_suggested: str | None = None
+ severity: str | None = None
+ atomic_test_id: str | None = None
+ suggested_remediation: str | None = None
+ team: str
+ submitted_by: uuid.UUID | None = None
+ submitter_name: str | None = None
+ status: str
+ reviewed_by: uuid.UUID | None = None
+ reviewed_at: datetime | None = None
+ created_template_id: uuid.UUID | None = None
+ created_at: datetime | None = None
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/services/template_suggestion_service.py b/backend/app/services/template_suggestion_service.py
new file mode 100644
index 0000000..e43484c
--- /dev/null
+++ b/backend/app/services/template_suggestion_service.py
@@ -0,0 +1,118 @@
+"""Operator template-proposal workflow.
+
+Leads create test templates directly (see test_template_service.create_template,
+gated to red_lead/blue_lead in the router). An operator (red_tech/blue_tech)
+gets the same "propose a template" action, but nothing is written to the
+live catalog until a lead on their team reviews it — the lead can approve
+as-is, approve with edits, or discard it outright.
+"""
+
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy.orm import Session
+
+from app.domain.errors import EntityNotFoundError, InvalidOperationError
+from app.models.template_suggestion import TemplateSuggestion
+from app.models.test_template import TestTemplate
+from app.models.user import User
+from app.services.notification_service import notify_role
+from app.services.test_template_service import create_template
+
+_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
+_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
+
+_TEMPLATE_FIELDS = (
+ "mitre_technique_id", "name", "description", "source", "source_url",
+ "attack_procedure", "expected_detection", "platform", "tool_suggested",
+ "severity", "atomic_test_id", "suggested_remediation",
+)
+
+
+def team_for_submitter(user: User) -> str:
+ """Which team a submission belongs to, based on the submitter's role."""
+ team = _TEAM_BY_ROLE.get(user.role)
+ if team is None:
+ raise InvalidOperationError("Only red_tech/blue_tech operators can propose templates")
+ return team
+
+
+def create_template_suggestion(db: Session, submitter: User, **fields: object) -> TemplateSuggestion:
+ """Create a pending template suggestion and notify the submitter's lead.
+
+ Does not commit; caller uses UnitOfWork.
+ """
+ team = team_for_submitter(submitter)
+ suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
+ db.add(suggestion)
+ db.flush()
+
+ notify_role(
+ db,
+ role=_LEAD_ROLE[team],
+ type="template_suggestion",
+ title="New test template proposed for review",
+ message=f'A new template, "{suggestion.name}", was proposed and needs your review.',
+ entity_type="template_suggestion",
+ entity_id=suggestion.id,
+ )
+ return suggestion
+
+
+def list_pending_template_suggestions(db: Session, *, team: str | None = None) -> list[TemplateSuggestion]:
+ """List pending template suggestions, optionally filtered to one team."""
+ query = db.query(TemplateSuggestion).filter(TemplateSuggestion.status == "pending")
+ if team is not None:
+ query = query.filter(TemplateSuggestion.team == team)
+ return query.order_by(TemplateSuggestion.created_at.asc()).all()
+
+
+def _get_pending_suggestion_or_raise(db: Session, suggestion_id: uuid.UUID) -> TemplateSuggestion:
+ suggestion = db.query(TemplateSuggestion).filter(TemplateSuggestion.id == suggestion_id).first()
+ if suggestion is None:
+ raise EntityNotFoundError("TemplateSuggestion", str(suggestion_id))
+ if suggestion.status != "pending":
+ raise InvalidOperationError("This template suggestion has already been reviewed.")
+ return suggestion
+
+
+def approve_template_suggestion(
+ db: Session,
+ suggestion_id: uuid.UUID,
+ user: User,
+ overrides: dict | None = None,
+) -> tuple[TemplateSuggestion, TestTemplate]:
+ """Approve a suggestion: create the real TestTemplate and mark it reviewed.
+
+ *overrides* lets the reviewing lead adjust any field before it goes
+ live — unset/None fields fall back to what the operator submitted.
+ Does not commit; caller uses UnitOfWork.
+ """
+ suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
+
+ fields = {name: getattr(suggestion, name) for name in _TEMPLATE_FIELDS}
+ for key, value in (overrides or {}).items():
+ if value is not None and key in fields:
+ fields[key] = value
+
+ template = create_template(db, **fields)
+ db.flush()
+
+ suggestion.status = "approved"
+ suggestion.reviewed_by = user.id
+ suggestion.reviewed_at = datetime.utcnow()
+ suggestion.created_template_id = template.id
+ db.flush()
+ return suggestion, template
+
+
+def reject_template_suggestion(db: Session, suggestion_id: uuid.UUID, user: User) -> TemplateSuggestion:
+ """Discard a suggestion without creating a template. Does not commit."""
+ suggestion = _get_pending_suggestion_or_raise(db, suggestion_id)
+ suggestion.status = "rejected"
+ suggestion.reviewed_by = user.id
+ suggestion.reviewed_at = datetime.utcnow()
+ db.flush()
+ return suggestion
diff --git a/backend/tests/test_template_suggestions.py b/backend/tests/test_template_suggestions.py
new file mode 100644
index 0000000..48cf142
--- /dev/null
+++ b/backend/tests/test_template_suggestions.py
@@ -0,0 +1,145 @@
+"""End-to-end coverage for the operator template-proposal review workflow.
+
+Leads create test templates directly via POST /test-templates. An operator
+(red_tech/blue_tech) gets the same "propose a template" action, but their
+submission lands in the review queue instead of the live catalog — a lead
+on their team approves (optionally editing fields) or discards it.
+"""
+
+import pytest
+
+
+def _payload(**overrides):
+ base = {
+ "mitre_technique_id": "T1059.300",
+ "name": "Operator-proposed template",
+ "attack_procedure": "Run a discovery command.",
+ "expected_detection": "Check process creation logs.",
+ }
+ base.update(overrides)
+ return base
+
+
+@pytest.fixture
+def technique(api, auth_headers):
+ resp = api(
+ "post", "/api/v1/techniques", auth_headers,
+ json={"mitre_id": "T1059.300", "name": "Command Line Proposals"},
+ )
+ assert resp.status_code == 201, resp.text
+ return resp.json()["id"]
+
+
+def test_red_tech_can_propose_a_template(api, technique, red_tech_headers):
+ resp = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ assert resp.status_code == 201, resp.text
+ body = resp.json()
+ assert body["status"] == "pending"
+ assert body["team"] == "red"
+ assert body["name"] == "Operator-proposed template"
+
+
+def test_blue_tech_can_propose_a_template(api, technique, blue_tech_headers):
+ resp = api("post", "/api/v1/template-suggestions", blue_tech_headers, json=_payload())
+ assert resp.status_code == 201, resp.text
+ assert resp.json()["team"] == "blue"
+
+
+def test_lead_cannot_propose_a_template_via_suggestion_endpoint(api, technique, red_lead_headers):
+ """Leads use the direct POST /test-templates endpoint instead."""
+ resp = api("post", "/api/v1/template-suggestions", red_lead_headers, json=_payload())
+ assert resp.status_code == 403
+
+
+def test_proposed_template_does_not_appear_in_catalog(api, technique, red_tech_headers, red_lead_headers):
+ api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+
+ catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
+ assert catalog.json() == []
+
+
+def test_red_lead_sees_pending_red_suggestion(api, technique, red_tech_headers, red_lead_headers):
+ api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+
+ listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
+ assert listed.status_code == 200, listed.text
+ assert len(listed.json()) == 1
+ assert listed.json()[0]["status"] == "pending"
+
+
+def test_blue_lead_does_not_see_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
+ api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+
+ listed = api("get", "/api/v1/template-suggestions", blue_lead_headers)
+ assert listed.json() == []
+
+
+def test_blue_lead_cannot_approve_a_red_suggestion(api, technique, red_tech_headers, blue_lead_headers):
+ created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ suggestion_id = created.json()["id"]
+
+ resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", blue_lead_headers)
+ assert resp.status_code == 403
+
+
+def test_lead_approves_suggestion_as_is_creates_template(api, technique, red_tech_headers, red_lead_headers):
+ created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ suggestion_id = created.json()["id"]
+
+ resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["status"] == "approved"
+ assert body["created_template_id"] is not None
+
+ template = api("get", f"/api/v1/test-templates/{body['created_template_id']}", red_lead_headers)
+ assert template.status_code == 200
+ assert template.json()["name"] == "Operator-proposed template"
+
+ listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
+ assert listed.json() == []
+
+
+def test_lead_approves_suggestion_with_edits(api, technique, red_tech_headers, red_lead_headers):
+ """The lead can add/adjust information before it goes live — the created
+ template reflects the lead's edits, not just the operator's original text."""
+ created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ suggestion_id = created.json()["id"]
+
+ resp = api(
+ "post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
+ json={"attack_procedure": "Run a discovery command, then pivot via WMI.", "severity": "high"},
+ )
+ assert resp.status_code == 200, resp.text
+ template_id = resp.json()["created_template_id"]
+
+ template = api("get", f"/api/v1/test-templates/{template_id}", red_lead_headers)
+ assert template.json()["attack_procedure"] == "Run a discovery command, then pivot via WMI."
+ assert template.json()["severity"] == "high"
+ # Untouched fields still come from the operator's original submission.
+ assert template.json()["expected_detection"] == "Check process creation logs."
+
+
+def test_lead_discards_suggestion_without_creating_template(api, technique, red_tech_headers, red_lead_headers):
+ created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ suggestion_id = created.json()["id"]
+
+ resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["status"] == "rejected"
+ assert resp.json()["created_template_id"] is None
+
+ catalog = api("get", "/api/v1/test-templates?search=Operator-proposed", red_lead_headers)
+ assert catalog.json() == []
+
+ listed = api("get", "/api/v1/template-suggestions", red_lead_headers)
+ assert listed.json() == []
+
+
+def test_cannot_review_an_already_reviewed_suggestion_twice(api, technique, red_tech_headers, red_lead_headers):
+ created = api("post", "/api/v1/template-suggestions", red_tech_headers, json=_payload())
+ suggestion_id = created.json()["id"]
+
+ api("post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers)
+ resp = api("post", f"/api/v1/template-suggestions/{suggestion_id}/reject", red_lead_headers)
+ assert resp.status_code == 400
diff --git a/frontend/src/api/template-suggestions.ts b/frontend/src/api/template-suggestions.ts
new file mode 100644
index 0000000..11a1f2d
--- /dev/null
+++ b/frontend/src/api/template-suggestions.ts
@@ -0,0 +1,38 @@
+import client from "./client";
+import type { TemplateSuggestion } from "../types/models";
+import type { CreateTemplatePayload } from "./test-templates";
+
+/** Propose a new test template. Lands in the review queue, not the live catalog. */
+export async function proposeTemplate(
+ payload: CreateTemplatePayload,
+): Promise {
+ const { data } = await client.post(
+ "/template-suggestions",
+ payload,
+ );
+ return data;
+}
+
+/** List pending template suggestions, scoped to the caller's team (admins see both). */
+export async function getTemplateSuggestions(): Promise {
+ const { data } = await client.get("/template-suggestions");
+ return data;
+}
+
+/** Approve a suggestion, optionally editing fields, creating the real template. */
+export async function approveTemplateSuggestion(
+ id: string,
+ overrides?: Partial,
+): Promise {
+ const { data } = await client.post(
+ `/template-suggestions/${id}/approve`,
+ overrides || {},
+ );
+ return data;
+}
+
+/** Discard a suggestion without creating a template. */
+export async function rejectTemplateSuggestion(id: string): Promise {
+ const { data } = await client.post(`/template-suggestions/${id}/reject`);
+ return data;
+}
diff --git a/frontend/src/components/CreateTemplateModal.tsx b/frontend/src/components/CreateTemplateModal.tsx
new file mode 100644
index 0000000..885133c
--- /dev/null
+++ b/frontend/src/components/CreateTemplateModal.tsx
@@ -0,0 +1,197 @@
+import { useState } from "react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { X, Loader2 } from "lucide-react";
+import { createTemplate, type CreateTemplatePayload } from "../api/test-templates";
+import { proposeTemplate } from "../api/template-suggestions";
+import { useToast } from "./Toast";
+
+const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
+const PLATFORM_OPTIONS = ["windows", "linux", "macos", "azure-ad", "office-365", "containers"];
+
+/** Leads create a template directly into the live catalog; operators
+ * (red_tech/blue_tech) propose one instead — it lands in the review
+ * queue for a lead on their team to approve (with optional edits) or
+ * discard, rather than going live immediately. */
+export default function CreateTemplateModal({
+ canCreateDirectly,
+ onClose,
+}: {
+ canCreateDirectly: boolean;
+ onClose: () => void;
+}) {
+ const queryClient = useQueryClient();
+ const { showToast } = useToast();
+
+ const [form, setForm] = useState({
+ mitre_technique_id: "",
+ name: "",
+ description: "",
+ source: "custom",
+ attack_procedure: "",
+ expected_detection: "",
+ platform: "",
+ severity: "",
+ });
+
+ const set = (field: keyof CreateTemplatePayload, value: string) =>
+ setForm((f) => ({ ...f, [field]: value }));
+
+ const directMutation = useMutation({
+ mutationFn: () => createTemplate(form),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["test-templates"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates-count"] });
+ showToast("success", "Template created and added to the catalog");
+ onClose();
+ },
+ onError: () => showToast("error", "Failed to create template"),
+ });
+
+ const proposeMutation = useMutation({
+ mutationFn: () => proposeTemplate(form),
+ onSuccess: () => {
+ showToast("success", "Template proposed — a lead will review it before it's added to the catalog");
+ onClose();
+ },
+ onError: () => showToast("error", "Failed to propose template"),
+ });
+
+ const isPending = directMutation.isPending || proposeMutation.isPending;
+ const canSubmit = form.mitre_technique_id.trim() !== "" && form.name.trim() !== "";
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!canSubmit) return;
+ if (canCreateDirectly) {
+ directMutation.mutate();
+ } else {
+ proposeMutation.mutate();
+ }
+ };
+
+ return (
+
+
+
+
+ {canCreateDirectly ? "Create Test Template" : "Propose Test Template"}
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/TestCatalogPage.tsx b/frontend/src/pages/TestCatalogPage.tsx
index b6022a1..3720485 100644
--- a/frontend/src/pages/TestCatalogPage.tsx
+++ b/frontend/src/pages/TestCatalogPage.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams, useParams } from "react-router-dom";
import {
Loader2,
@@ -11,9 +11,19 @@ import {
FlaskConical,
X,
AlertTriangle,
+ Plus,
+ ClipboardList,
+ CheckCircle,
+ XCircle,
} from "lucide-react";
import { getTemplates, getTemplateCount } from "../api/test-templates";
+import {
+ getTemplateSuggestions,
+ approveTemplateSuggestion,
+ rejectTemplateSuggestion,
+} from "../api/template-suggestions";
import TestFromTemplateForm from "../components/TestFromTemplateForm";
+import CreateTemplateModal from "../components/CreateTemplateModal";
import type { TestTemplateSummary } from "../types/models";
import { useAuth } from "../context/AuthContext";
@@ -86,6 +96,14 @@ export default function TestCatalogPage() {
user?.role === "red_lead" ||
user?.role === "blue_lead";
+ // Leads create templates straight into the catalog; operators get the
+ // same button but their submission goes through lead review first.
+ const canCreateTemplateDirectly = user?.role === "red_lead" || user?.role === "blue_lead";
+ const canProposeTemplate = user?.role === "red_tech" || user?.role === "blue_tech";
+ const isReviewLead = user?.role === "red_lead" || user?.role === "blue_lead" || user?.role === "admin";
+
+ const [showCreateModal, setShowCreateModal] = useState(false);
+
const [search, setSearch] = useState(searchParams.get("search") || "");
const [source, setSource] = useState(searchParams.get("source") || "");
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
@@ -166,9 +184,20 @@ export default function TestCatalogPage() {
Browse available test templates. Use a template to quickly create a security test.
-
- {totalCount} template{totalCount !== 1 ? "s" : ""} available
-
+
+
+ {totalCount} template{totalCount !== 1 ? "s" : ""} available
+
+ {(canCreateTemplateDirectly || canProposeTemplate) && (
+
+ )}
+
{/* Filters bar */}
@@ -337,6 +366,12 @@ export default function TestCatalogPage() {
>
)}
+ {/* ── Template suggestions for leads ────────────────────────────
+ GET /template-suggestions is already scoped server-side to the
+ caller's own team (red_lead sees only red, blue_lead only blue;
+ admin sees both), so no client-side filtering is needed here. */}
+ {isReviewLead && }
+
{/* Template instantiation modal */}
{templateId && (
navigate("/test-catalog")}
/>
)}
+
+ {/* Create/propose template modal */}
+ {showCreateModal && (
+ setShowCreateModal(false)}
+ />
+ )}
+
+ );
+}
+
+// ── Template suggestions panel (leads only) ─────────────────────────
+
+function TemplateSuggestionsPanel() {
+ const queryClient = useQueryClient();
+
+ const { data: suggestions = [] } = useQuery({
+ queryKey: ["template-suggestions"],
+ queryFn: getTemplateSuggestions,
+ });
+
+ const invalidate = () => {
+ queryClient.invalidateQueries({ queryKey: ["template-suggestions"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates"] });
+ queryClient.invalidateQueries({ queryKey: ["test-templates-count"] });
+ };
+ const approveMutation = useMutation({
+ mutationFn: (id: string) => approveTemplateSuggestion(id),
+ onSuccess: invalidate,
+ });
+ const rejectMutation = useMutation({
+ mutationFn: rejectTemplateSuggestion,
+ onSuccess: invalidate,
+ });
+ const isBusy = approveMutation.isPending || rejectMutation.isPending;
+
+ if (suggestions.length === 0) return null;
+
+ return (
+
+
+
+
Template Suggestions
+
+ {suggestions.length}
+
+ Proposed by operators, pending your review
+
+
+ {suggestions.map((s) => (
+
+
+
{s.name}
+
+ {s.mitre_technique_id}
+
+
+ {s.submitter_name && (
+
Proposed by {s.submitter_name}
+ )}
+ {s.description &&
{s.description}
}
+ {s.attack_procedure && (
+
+
Suggested Attack Procedure
+
+ {s.attack_procedure}
+
+
+ )}
+ {s.expected_detection && (
+
+
Expected Detection
+
+ {s.expected_detection}
+
+
+ )}
+
+
+
+
+
+ ))}
+
);
}
diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts
index a59a6e5..ffbb78d 100644
--- a/frontend/src/types/models.ts
+++ b/frontend/src/types/models.ts
@@ -256,6 +256,32 @@ export interface ProcedureSuggestion {
template_current_text: string | null;
}
+// ── Template suggestions (operator-proposed templates) ──────────────
+
+export interface TemplateSuggestion {
+ id: string;
+ mitre_technique_id: string;
+ name: string;
+ description: string | null;
+ source: string;
+ source_url: string | null;
+ attack_procedure: string | null;
+ expected_detection: string | null;
+ platform: string | null;
+ tool_suggested: string | null;
+ severity: string | null;
+ atomic_test_id: string | null;
+ suggested_remediation: string | null;
+ team: TeamSide;
+ submitted_by: string | null;
+ submitter_name: string | null;
+ status: "pending" | "approved" | "rejected";
+ reviewed_by: string | null;
+ reviewed_at: string | null;
+ created_template_id: string | null;
+ created_at: string | null;
+}
+
export interface TestTemplateSummary {
id: string;
mitre_technique_id: string;