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
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:
@@ -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")
|
||||||
@@ -44,6 +44,7 @@ from app.routers import tests as tests_router
|
|||||||
from app.routers import evidence as evidence_router
|
from app.routers import evidence as evidence_router
|
||||||
from app.routers import test_templates as test_templates_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 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 system as system_router
|
||||||
from app.routers import metrics as metrics_router
|
from app.routers import metrics as metrics_router
|
||||||
from app.routers import users as users_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()
|
# Call app.include_router()
|
||||||
app.include_router(test_templates_router.router, prefix="/api/v1")
|
app.include_router(test_templates_router.router, prefix="/api/v1")
|
||||||
app.include_router(procedure_suggestions_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()
|
# Call app.include_router()
|
||||||
app.include_router(system_router.router, prefix="/api/v1")
|
app.include_router(system_router.router, prefix="/api/v1")
|
||||||
# Call app.include_router()
|
# Call app.include_router()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from app.models.test import Test
|
|||||||
from app.models.test_round_history import TestRoundHistory
|
from app.models.test_round_history import TestRoundHistory
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
from app.models.procedure_suggestion import ProcedureSuggestion
|
from app.models.procedure_suggestion import ProcedureSuggestion
|
||||||
|
from app.models.template_suggestion import TemplateSuggestion
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
# Assign __all__ = [
|
# Assign __all__ = [
|
||||||
@@ -90,4 +91,5 @@ __all__ = [
|
|||||||
"AlertInstance",
|
"AlertInstance",
|
||||||
"TestRoundHistory",
|
"TestRoundHistory",
|
||||||
"ProcedureSuggestion",
|
"ProcedureSuggestion",
|
||||||
|
"TemplateSuggestion",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
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 { useNavigate, useSearchParams, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Plus,
|
||||||
|
ClipboardList,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTemplates, getTemplateCount } from "../api/test-templates";
|
import { getTemplates, getTemplateCount } from "../api/test-templates";
|
||||||
|
import {
|
||||||
|
getTemplateSuggestions,
|
||||||
|
approveTemplateSuggestion,
|
||||||
|
rejectTemplateSuggestion,
|
||||||
|
} from "../api/template-suggestions";
|
||||||
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
import TestFromTemplateForm from "../components/TestFromTemplateForm";
|
||||||
|
import CreateTemplateModal from "../components/CreateTemplateModal";
|
||||||
import type { TestTemplateSummary } from "../types/models";
|
import type { TestTemplateSummary } from "../types/models";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
@@ -86,6 +96,14 @@ export default function TestCatalogPage() {
|
|||||||
user?.role === "red_lead" ||
|
user?.role === "red_lead" ||
|
||||||
user?.role === "blue_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 [search, setSearch] = useState(searchParams.get("search") || "");
|
||||||
const [source, setSource] = useState(searchParams.get("source") || "");
|
const [source, setSource] = useState(searchParams.get("source") || "");
|
||||||
const [platform, setPlatform] = useState(searchParams.get("platform") || "");
|
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.
|
Browse available test templates. Use a template to quickly create a security test.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-sm font-medium text-gray-300">
|
<div className="flex items-center gap-3">
|
||||||
{totalCount} template{totalCount !== 1 ? "s" : ""} available
|
<span className="rounded-full border border-gray-700 bg-gray-800 px-3 py-1 text-sm font-medium text-gray-300">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Filters bar */}
|
{/* 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 */}
|
{/* Template instantiation modal */}
|
||||||
{templateId && (
|
{templateId && (
|
||||||
<TestFromTemplateForm
|
<TestFromTemplateForm
|
||||||
@@ -344,6 +379,103 @@ export default function TestCatalogPage() {
|
|||||||
onClose={() => navigate("/test-catalog")}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,32 @@ export interface ProcedureSuggestion {
|
|||||||
template_current_text: string | null;
|
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 {
|
export interface TestTemplateSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mitre_technique_id: string;
|
mitre_technique_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user