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
+ {canCreateDirectly ? "Create Test Template" : "Propose Test Template"}
+
+
+
{s.name}
+ + {s.mitre_technique_id} + +Proposed by {s.submitter_name}
+ )} + {s.description &&{s.description}
} + {s.attack_procedure && ( +Suggested Attack Procedure
+
+ {s.attack_procedure}
+
+ Expected Detection
+
+ {s.expected_detection}
+
+