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:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user