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"} +

+ +
+ +
+ {!canCreateDirectly && ( +

+ This will be submitted for review — a lead on your team must approve it before it appears in the catalog. +

+ )} + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ +