Files
Aegis/backend/app/services/template_suggestion_service.py
T
kitos c753797019
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
feat(test-catalog): add template creation and lead-approval workflow
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.
2026-07-16 12:08:23 +02:00

119 lines
4.4 KiB
Python

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