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
@@ -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")
+2
View File
@@ -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()
+2
View File
@@ -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",
]
+53
View File
@@ -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])
+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")
@@ -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)
@@ -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
+145
View File
@@ -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
+38
View File
@@ -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<TemplateSuggestion> {
const { data } = await client.post<TemplateSuggestion>(
"/template-suggestions",
payload,
);
return data;
}
/** List pending template suggestions, scoped to the caller's team (admins see both). */
export async function getTemplateSuggestions(): Promise<TemplateSuggestion[]> {
const { data } = await client.get<TemplateSuggestion[]>("/template-suggestions");
return data;
}
/** Approve a suggestion, optionally editing fields, creating the real template. */
export async function approveTemplateSuggestion(
id: string,
overrides?: Partial<CreateTemplatePayload>,
): Promise<TemplateSuggestion> {
const { data } = await client.post<TemplateSuggestion>(
`/template-suggestions/${id}/approve`,
overrides || {},
);
return data;
}
/** Discard a suggestion without creating a template. */
export async function rejectTemplateSuggestion(id: string): Promise<TemplateSuggestion> {
const { data } = await client.post<TemplateSuggestion>(`/template-suggestions/${id}/reject`);
return data;
}
@@ -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<CreateTemplatePayload>({
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-2xl rounded-xl border border-gray-800 bg-gray-900 shadow-xl">
<div className="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<h2 className="text-lg font-semibold text-white">
{canCreateDirectly ? "Create Test Template" : "Propose Test Template"}
</h2>
<button onClick={onClose} className="rounded-lg p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="max-h-[70vh] space-y-4 overflow-y-auto px-6 py-4">
{!canCreateDirectly && (
<p className="rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-300">
This will be submitted for review a lead on your team must approve it before it appears in the catalog.
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
MITRE Technique ID <span className="text-red-400">*</span>
</label>
<input
required
value={form.mitre_technique_id}
onChange={(e) => set("mitre_technique_id", e.target.value)}
placeholder="T1059.001"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Name <span className="text-red-400">*</span>
</label>
<input
required
value={form.name}
onChange={(e) => set("name", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Description</label>
<textarea
rows={2}
value={form.description || ""}
onChange={(e) => set("description", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Platform</label>
<select
value={form.platform || ""}
onChange={(e) => set("platform", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">Unspecified</option>
{PLATFORM_OPTIONS.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Severity</label>
<select
value={form.severity || ""}
onChange={(e) => set("severity", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="">Unspecified</option>
{SEVERITY_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Suggested Attack Procedure</label>
<textarea
rows={3}
value={form.attack_procedure || ""}
onChange={(e) => set("attack_procedure", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">Expected Detection</label>
<textarea
rows={3}
value={form.expected_detection || ""}
onChange={(e) => set("expected_detection", e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800"
>
Cancel
</button>
<button
type="submit"
disabled={!canSubmit || isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{canCreateDirectly ? "Create Template" : "Submit for Review"}
</button>
</div>
</form>
</div>
</div>
);
}
+136 -4
View File
@@ -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.
</p>
</div>
<span className="rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-sm font-medium text-gray-300">
{totalCount} template{totalCount !== 1 ? "s" : ""} available
</span>
<div className="flex items-center gap-3">
<span className="rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-sm font-medium text-gray-300">
{totalCount} template{totalCount !== 1 ? "s" : ""} available
</span>
{(canCreateTemplateDirectly || canProposeTemplate) && (
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
{canCreateTemplateDirectly ? "Create Template" : "Propose Template"}
</button>
)}
</div>
</div>
{/* 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 && <TemplateSuggestionsPanel />}
{/* Template instantiation modal */}
{templateId && (
<TestFromTemplateForm
@@ -344,6 +379,103 @@ export default function TestCatalogPage() {
onClose={() => navigate("/test-catalog")}
/>
)}
{/* Create/propose template modal */}
{showCreateModal && (
<CreateTemplateModal
canCreateDirectly={canCreateTemplateDirectly}
onClose={() => setShowCreateModal(false)}
/>
)}
</div>
);
}
// ── 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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white">Template Suggestions</h2>
<span className="rounded-full border border-gray-600 bg-gray-800 px-2 py-0.5 text-xs font-medium text-gray-300">
{suggestions.length}
</span>
<span className="text-xs text-gray-500">Proposed by operators, pending your review</span>
</div>
<div className="space-y-3">
{suggestions.map((s) => (
<div key={s.id} className="rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium text-gray-200">{s.name}</p>
<span className="rounded-full border border-gray-600/30 bg-gray-800/50 px-2 py-0.5 text-xs text-gray-400">
{s.mitre_technique_id}
</span>
</div>
{s.submitter_name && (
<p className="mt-0.5 text-xs text-gray-500">Proposed by {s.submitter_name}</p>
)}
{s.description && <p className="mt-2 text-sm text-gray-400">{s.description}</p>}
{s.attack_procedure && (
<div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.attack_procedure}
</pre>
</div>
)}
{s.expected_detection && (
<div className="mt-2">
<p className="text-xs font-medium uppercase text-gray-500">Expected Detection</p>
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
{s.expected_detection}
</pre>
</div>
)}
<div className="mt-3 flex gap-2">
<button
onClick={() => approveMutation.mutate(s.id)}
disabled={isBusy}
className="flex items-center gap-1 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-1.5 text-xs font-medium text-green-400 hover:bg-green-900/40 disabled:opacity-50"
>
<CheckCircle className="h-3.5 w-3.5" /> Approve
</button>
<button
onClick={() => rejectMutation.mutate(s.id)}
disabled={isBusy}
className="flex items-center gap-1 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 disabled:opacity-50"
>
<XCircle className="h-3.5 w-3.5" /> Discard
</button>
</div>
</div>
))}
</div>
</div>
);
}
+26
View File
@@ -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;