Files
Aegis/backend/app/routers/campaigns.py
T
kitos cf4a6c3cde
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
fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
- Approve endpoint now only creates Jira tickets immediately when
  start_date is now/past; a new periodic job (every 15 min) catches
  campaigns whose scheduled start_date has since arrived.
- Recurring campaign clones now go to pending_approval instead of
  active, routing through the same manager-approval gate as any other
  campaign; managers are notified instead of red_tech.
- Fix UTC conversion for the campaign approval start_date input and
  extract shared isoToDatetimeLocal/datetimeLocalToIso helpers.
2026-07-16 11:09:10 +02:00

1219 lines
42 KiB
Python

"""Campaign endpoints — CRUD, test management, activation, and auto-generation.
Provides comprehensive campaign lifecycle management including test ordering,
progress tracking, and threat actor integration.
"""
# Import logging
import logging
# Import uuid
import uuid
from datetime import datetime
from typing import Optional
# Import APIRouter, Depends, Query from fastapi
from fastapi import APIRouter, Depends, Query
# Import BaseModel, Field from pydantic
from pydantic import BaseModel, Field
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import User from app.models.user
from app.models.user import User
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test
from app.services.campaign_service import generate_campaign_from_threat_actor
from app.services.campaign_crud_service import (
add_test_to_campaign as crud_add_test,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
complete_campaign as crud_complete,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
create_campaign as crud_create,
delete_campaign as crud_delete,
get_campaign_detail as crud_get_detail,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_history as crud_get_history,
)
from app.services.campaign_crud_service import (
get_campaign_timeline as crud_get_timeline,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_progress_data as crud_get_progress,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
list_campaigns as crud_list,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
remove_test_from_campaign as crud_remove_test,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
schedule_campaign as crud_schedule,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
serialize_campaign,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
update_campaign as crud_update,
)
# Import activate_campaign from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
activate_campaign as crud_activate,
)
from app.services.campaign_crud_service import (
submit_campaign_for_approval as crud_submit,
)
from app.services.campaign_crud_service import (
approve_campaign as crud_approve,
)
from app.services.campaign_crud_service import (
reject_campaign as crud_reject,
)
from app.services.campaign_crud_service import (
create_modification_request as crud_create_mod_request,
)
from app.services.campaign_crud_service import (
approve_modification_request as crud_approve_mod_request,
)
from app.services.campaign_crud_service import (
reject_modification_request as crud_reject_mod_request,
)
from app.services.campaign_crud_service import (
list_modification_requests as crud_list_mod_requests,
)
from app.services.campaign_crud_service import (
serialize_modification_request as crud_serialize_mod_request,
)
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import notify_role from app.services.notification_service
from app.services.notification_service import notify_role
from app.services.webhook_service import dispatch_webhook
# Assign logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Assign router = APIRouter(prefix="/campaigns", tags=["campaigns"])
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
def _create_jira_tickets_for_campaign(db: Session, campaign: Campaign, campaign_id: str, user: User) -> None:
"""Create Jira tickets for *campaign* now, if its start_date has arrived.
Shared by both paths that bring a campaign to ``active``: the admin-only
emergency `/activate` override and the normal manager `/approve` flow.
If ``start_date`` is still in the future, ticket creation is skipped
here and left to the periodic ``sync_due_campaign_jira_tickets`` job,
which creates them once that date actually arrives — this is what makes
the campaign's real scheduled date/time authoritative for when tickets
(and their Jira start-date field) appear, instead of always at approval
time.
"""
if campaign.start_date and campaign.start_date > datetime.utcnow():
return
from app.services.jira_service import ensure_campaign_jira_tickets
ensure_campaign_jira_tickets(db, campaign, user)
# ── Pydantic schemas ─────────────────────────────────────────────────
class CampaignCreate(BaseModel):
"""Payload for creating a new campaign."""
# name: str
name: str
# Assign description = None
description: Optional[str] = None
# Assign type = "custom"
type: str = "custom"
# Assign threat_actor_id = None
threat_actor_id: Optional[str] = None
# Assign target_platform = None
target_platform: Optional[str] = None
# Assign tags = Field(default_factory=list)
tags: Optional[list[str]] = Field(default_factory=list)
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
# Define class CampaignUpdate
class CampaignUpdate(BaseModel):
"""Payload for updating an existing campaign's metadata."""
# Assign name = None
name: Optional[str] = None
# Assign description = None
description: Optional[str] = None
# Assign type = None
type: Optional[str] = None
# Assign target_platform = None
target_platform: Optional[str] = None
# Assign tags = None
tags: Optional[list[str]] = None
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
# Define class AddTestPayload
class AddTestPayload(BaseModel):
"""Payload for adding a test to a campaign."""
# test_id: str
test_id: str
# Assign order_index = None
order_index: Optional[int] = None
# Assign depends_on = None
depends_on: Optional[str] = None
# Assign phase = None
phase: Optional[str] = None
# Define class SchedulePayload
class SchedulePayload(BaseModel):
"""Payload for scheduling or rescheduling a campaign run."""
# is_recurring: bool
is_recurring: bool
# Assign recurrence_pattern = None # weekly, monthly, quarterly
recurrence_pattern: Optional[str] = None # weekly, monthly, quarterly
# Assign next_run_at = None
next_run_at: Optional[str] = None
class ApprovePayload(BaseModel):
"""Payload for a manager approving a pending campaign."""
start_date: str # ISO date/datetime — required, campaign won't activate without it
class RejectPayload(BaseModel):
"""Payload for a manager rejecting a pending campaign."""
reason: str
class ModificationRequestPayload(BaseModel):
"""Payload for a lead requesting to add/remove a test on an active campaign."""
action: str # add_test | remove_test
test_id: str
justification: str
order_index: Optional[int] = None
phase: Optional[str] = None
class RejectModificationPayload(BaseModel):
"""Payload for a manager rejecting a modification request."""
review_notes: str
# ---------------------------------------------------------------------------
# GET /campaigns — List campaigns with filters
# ---------------------------------------------------------------------------
@router.get("")
# Define function list_campaigns
def list_campaigns(
# Entry: type
type: Optional[str] = Query(None),
# Entry: status
status: Optional[str] = Query(None),
# Entry: threat_actor_id
threat_actor_id: Optional[str] = Query(None),
# Entry: search
search: Optional[str] = Query(None),
# Entry: offset
offset: int = Query(0, ge=0),
# Entry: limit
limit: int = Query(50, ge=1, le=200),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""List campaigns with optional filters and pagination.
Args:
type (Optional[str]): Filter by campaign type (e.g. ``custom``, ``threat_actor``).
status (Optional[str]): Filter by campaign status (e.g. ``draft``, ``active``).
threat_actor_id (Optional[str]): Filter campaigns linked to a specific threat actor.
search (Optional[str]): Free-text search against campaign name.
offset (int): Number of records to skip for pagination.
limit (int): Maximum number of records to return.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Serialised list of campaign summary dicts.
"""
# Return crud_list(
return crud_list(
db,
# Keyword argument: type
type=type,
# Keyword argument: status
status=status,
# Keyword argument: threat_actor_id
threat_actor_id=threat_actor_id,
# Keyword argument: search
search=search,
# Keyword argument: offset
offset=offset,
# Keyword argument: limit
limit=limit,
)
# ---------------------------------------------------------------------------
# POST /campaigns — Create campaign
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
# Define function create_campaign
def create_campaign(
# Entry: payload
payload: CampaignCreate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Create a new campaign.
Args:
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
Returns:
dict: Serialised representation of the newly created campaign.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = crud_create(
result = crud_create(
db,
# Keyword argument: creator_id
creator_id=current_user.id,
# Keyword argument: name
name=payload.name,
# Keyword argument: description
description=payload.description,
# Keyword argument: type
type=payload.type,
# Keyword argument: threat_actor_id
threat_actor_id=payload.threat_actor_id,
# Keyword argument: target_platform
target_platform=payload.target_platform,
# Keyword argument: tags
tags=payload.tags,
# Keyword argument: scheduled_at
scheduled_at=payload.scheduled_at,
)
campaign_id = result["id"]
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="create_campaign",
# Keyword argument: entity_type
entity_type="campaign",
entity_id=campaign_id,
details={"name": payload.name, "type": payload.type},
)
# Call uow.commit()
uow.commit()
# Return result
return result
# ---------------------------------------------------------------------------
# GET /campaigns/{id} — Detail with tests and progress
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}")
# Define function get_campaign
def get_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get detailed campaign info including tests and progress.
Args:
campaign_id (str): UUID string of the campaign to retrieve.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Campaign detail including associated tests and progress metrics.
"""
# Return crud_get_detail(db, campaign_id)
return crud_get_detail(db, campaign_id)
# ---------------------------------------------------------------------------
# PATCH /campaigns/{id} — Update campaign
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}")
# Define function update_campaign
def update_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: CampaignUpdate,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Update a campaign. Only allowed in draft or active state.
Args:
campaign_id (str): UUID string of the campaign to update.
payload (CampaignUpdate): Partial update payload; only set fields are applied.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead performing the update.
Returns:
dict: Serialised representation of the updated campaign.
"""
# Assign update_data = payload.model_dump(exclude_unset=True)
update_data = payload.model_dump(exclude_unset=True)
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = crud_update(
result = crud_update(
db,
campaign_id,
# Keyword argument: updater_id
updater_id=current_user.id,
# Keyword argument: updater_role
updater_role=current_user.role,
**update_data,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="update_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign_id,
# Keyword argument: details
details={"updated_fields": list(update_data.keys())},
)
# Call uow.commit()
uow.commit()
# Return result
return result
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/submit — Submit draft for manager approval
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/submit")
def submit_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Submit a draft campaign into the manager's approval queue."""
with UnitOfWork(db) as uow:
campaign = crud_submit(
db, campaign_id,
submitter_id=current_user.id,
submitter_role=current_user.role,
)
log_action(
db,
user_id=current_user.id,
action="submit_campaign_for_approval",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/approve — Manager approves a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/approve")
def approve_campaign_endpoint(
campaign_id: str,
payload: ApprovePayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a pending campaign, fixing its start date and activating it."""
with UnitOfWork(db) as uow:
campaign = crud_approve(
db, campaign_id,
approver_id=current_user.id,
start_date=payload.start_date,
)
log_action(
db,
user_id=current_user.id,
action="approve_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"start_date": payload.start_date},
)
uow.commit()
db.refresh(campaign)
# Create Jira tickets for campaign and its already-linked tests (non-fatal).
# Mirrors the admin-only /activate override — this is the normal path.
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/reject")
def reject_campaign_endpoint(
campaign_id: str,
payload: RejectPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a pending campaign, returning it to draft with a reason."""
with UnitOfWork(db) as uow:
campaign = crud_reject(
db, campaign_id,
rejecter_id=current_user.id,
reason=payload.reason,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"reason": payload.reason},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/modification-requests — Request a test add/remove
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/modification-requests", status_code=201)
def create_modification_request_endpoint(
campaign_id: str,
payload: ModificationRequestPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""File a request to add/remove a test on an active campaign — needs manager approval."""
with UnitOfWork(db) as uow:
request = crud_create_mod_request(
db, campaign_id,
requester_id=current_user.id,
action=payload.action,
test_id=payload.test_id,
justification=payload.justification,
order_index=payload.order_index,
phase=payload.phase,
)
log_action(
db,
user_id=current_user.id,
action="request_campaign_modification",
entity_type="campaign",
entity_id=campaign_id,
details={
"action": payload.action,
"test_id": payload.test_id,
"justification": payload.justification,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/modification-requests — List requests for one campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/modification-requests")
def list_campaign_modification_requests_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""List modification requests filed against a specific campaign."""
return crud_list_mod_requests(db, campaign_id=campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/modification-requests/pending — Manager's global queue
# ---------------------------------------------------------------------------
@router.get("/modification-requests/pending")
def list_pending_modification_requests_endpoint(
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> list:
"""List all modification requests awaiting manager review, across all campaigns."""
return crud_list_mod_requests(db, status="pending")
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/approve")
def approve_modification_request_endpoint(
request_id: str,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a modification request — the test change is applied now."""
with UnitOfWork(db) as uow:
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
log_action(
db,
user_id=current_user.id,
action="approve_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={
"request_id": request_id,
"action": request.action,
"test_id": str(request.test_id) if request.test_id else None,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/reject")
def reject_modification_request_endpoint(
request_id: str,
payload: RejectModificationPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a modification request. No change is applied."""
with UnitOfWork(db) as uow:
request = crud_reject_mod_request(
db, request_id,
reviewer_id=current_user.id,
review_notes=payload.review_notes,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={"request_id": request_id, "review_notes": payload.review_notes},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# DELETE /campaigns/{id} — Delete campaign
# ---------------------------------------------------------------------------
@router.delete("/{campaign_id}", status_code=204)
def delete_campaign(
campaign_id: str,
delete_tests: bool = Query(False, description="Also delete associated tests"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a campaign. Only draft campaigns can be deleted (admins can delete any)."""
with UnitOfWork(db) as uow:
crud_delete(
db,
campaign_id,
deleter_id=current_user.id,
deleter_role=current_user.role,
delete_tests=delete_tests,
)
log_action(
db,
user_id=current_user.id,
action="delete_campaign",
entity_type="campaign",
entity_id=campaign_id,
details={"delete_tests": delete_tests},
)
uow.commit()
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/tests — Add test to campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/tests")
# Define function add_test_to_campaign
def add_test_to_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: AddTestPayload,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Add a test to a campaign with optional ordering and dependency.
Args:
campaign_id (str): UUID string of the target campaign.
payload (AddTestPayload): Test ID plus optional order index, dependency, and phase.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead adding the test.
Returns:
dict: The created campaign-test association record.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign result = crud_add_test(
result = crud_add_test(
db,
campaign_id,
# Keyword argument: test_id
test_id=payload.test_id,
# Keyword argument: order_index
order_index=payload.order_index,
# Keyword argument: depends_on
depends_on=payload.depends_on,
# Keyword argument: phase
phase=payload.phase,
)
# Call uow.commit()
uow.commit()
return result
# ---------------------------------------------------------------------------
# DELETE /campaigns/{id}/tests/{campaign_test_id} — Remove test from campaign
# ---------------------------------------------------------------------------
@router.delete("/{campaign_id}/tests/{campaign_test_id}")
# Define function remove_test_from_campaign
def remove_test_from_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: campaign_test_id
campaign_test_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Remove a test from a campaign.
Args:
campaign_id (str): UUID string of the campaign.
campaign_test_id (str): UUID string of the campaign-test association to remove.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead removing the test.
Returns:
dict: Confirmation message with key ``detail``.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Call crud_remove_test()
crud_remove_test(db, campaign_id, campaign_test_id)
# Call uow.commit()
uow.commit()
# Return {"detail": "Test removed from campaign"}
return {"detail": "Test removed from campaign"}
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/activate — Activate campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/activate")
# Define function activate_campaign
def activate_campaign(
# Entry: campaign_id
campaign_id: str,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("admin")),
):
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue.
Every other path to 'active' goes through /submit -> /approve, where the
manager sets start_date. A draft campaign never has a start_date set
(only /approve sets it, in the same call that moves the campaign to
'active'), so there is no "scheduled for the future" case to guard
against here anymore.
"""
with UnitOfWork(db) as uow:
# Assign campaign = crud_activate(db, campaign_id)
campaign = crud_activate(db, campaign_id)
# Call notify_role()
notify_role(
db,
# Keyword argument: role
role="red_tech",
# Keyword argument: type
type="campaign_activated",
# Keyword argument: title
title="Campaign activated",
# Keyword argument: message
message=f'Campaign "{campaign.name}" has been activated.',
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="activate_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
# Create Jira tickets for campaign and tests at activation time (non-fatal).
# Campaign ticket is created here if it doesn't already exist (deferred from creation).
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/complete — Mark campaign as completed
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/complete")
# Define function complete_campaign
def complete_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "admin")),
) -> dict:
"""Mark a campaign as completed.
Args:
campaign_id (str): UUID string of the campaign to complete.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or admin completing the campaign.
Returns:
dict: Serialised representation of the completed campaign.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign campaign = crud_complete(db, campaign_id)
campaign = crud_complete(db, campaign_id)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="complete_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
dispatch_webhook("campaign.completed", {"campaign_id": str(campaign.id), "name": campaign.name})
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/progress — Campaign progress
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/progress")
# Define function get_campaign_progress_endpoint
def get_campaign_progress_endpoint(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Get progress statistics for a campaign.
Args:
campaign_id (str): UUID string of the campaign.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
dict: Progress breakdown including counts by test state and overall percentage.
"""
# Return crud_get_progress(db, campaign_id)
return crud_get_progress(db, campaign_id)
# ---------------------------------------------------------------------------
# POST /campaigns/from-threat-actor/{actor_id} — Auto-generate campaign
# ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
start_date: Optional[datetime] = None
@router.post("/from-threat-actor/{actor_id}", status_code=201)
# Define function generate_campaign_from_actor
def generate_campaign_from_actor(
# Entry: actor_id
actor_id: str,
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
Creates tests from the best available templates and orders them
by kill chain phase.
Args:
actor_id (str): UUID string of the threat actor to generate a campaign for.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead requesting the generation.
Returns:
dict: Serialised representation of the newly generated campaign.
"""
campaign = generate_campaign_from_threat_actor(
db,
uuid.UUID(actor_id),
current_user,
start_date=payload.start_date,
)
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="generate_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={"actor_id": actor_id, "campaign_name": campaign.name},
)
# Call uow.commit()
uow.commit()
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# PATCH /campaigns/{id}/schedule — Configure recurrence
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}/schedule")
# Define function schedule_campaign
def schedule_campaign(
# Entry: campaign_id
campaign_id: str,
# Entry: payload
payload: SchedulePayload,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Configure or update the recurrence schedule for a campaign.
Only the campaign creator or admin can change scheduling.
Args:
campaign_id (str): UUID string of the campaign to schedule.
payload (SchedulePayload): Recurrence flag, pattern, and next run timestamp.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated red_lead or blue_lead (must be owner or admin).
Returns:
dict: Serialised representation of the campaign with updated schedule fields.
"""
# Open context manager
with UnitOfWork(db) as uow:
# Assign campaign = crud_schedule(
campaign = crud_schedule(
db,
campaign_id,
# Keyword argument: owner_id
owner_id=current_user.id,
# Keyword argument: owner_role
owner_role=current_user.role,
# Keyword argument: is_recurring
is_recurring=payload.is_recurring,
# Keyword argument: recurrence_pattern
recurrence_pattern=payload.recurrence_pattern,
# Keyword argument: next_run_at
next_run_at=payload.next_run_at,
)
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="schedule_campaign",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=campaign.id,
# Keyword argument: details
details={
# Literal argument value
"is_recurring": campaign.is_recurring,
# Literal argument value
"recurrence_pattern": campaign.recurrence_pattern,
# Literal argument value
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(campaign)
# Return serialize_campaign(db, campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/history — Execution history (child campaigns)
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/history")
# Define function get_campaign_history
def get_campaign_history(
# Entry: campaign_id
campaign_id: str,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list:
"""List all child campaigns (execution history) of a recurring campaign.
Args:
campaign_id (str): UUID string of the parent recurring campaign.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated user making the request.
Returns:
list: Serialised list of child campaign dicts ordered by creation date.
"""
# Return crud_get_history(db, campaign_id)
return crud_get_history(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/timeline")
def get_campaign_timeline_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""Return the chronological audit-log history for a campaign."""
return crud_get_timeline(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
# ---------------------------------------------------------------------------
def _seconds_between(start: datetime | None, end: datetime | None) -> int:
"""Return elapsed seconds between two datetimes; 0 if either is None."""
if not start or not end:
return 0
diff = (end - start).total_seconds()
return max(0, int(diff))
@router.get("/{campaign_id}/timing-summary")
def get_campaign_timing_summary(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return aggregated Red/Blue timing metrics for all tests in a campaign.
For each test we calculate:
- red_execution_secs : red_started_at → blue_started_at (minus red_paused_seconds)
- blue_queue_secs : blue_started_at → blue_work_started_at (waiting for Blue pick-up)
- blue_evaluation_secs: blue_work_started_at → first validation timestamp (minus blue_paused_seconds)
- total_secs : sum of the three phases
Returns totals + per-test breakdown.
"""
# Load campaign
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Campaign not found")
# Load all tests for this campaign
test_ids = [
ct.test_id
for ct in db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
]
tests = db.query(Test).filter(Test.id.in_(test_ids)).all() if test_ids else []
breakdown = []
total_red = 0
total_queue = 0
total_blue = 0
for t in tests:
# Red execution: from start-execution to submit-to-blue, minus paused time
red_secs = max(
0,
_seconds_between(t.red_started_at, t.blue_started_at) - (t.red_paused_seconds or 0),
)
# Blue queue: from receiving the test to actually starting evaluation
queue_secs = _seconds_between(t.blue_started_at, t.blue_work_started_at)
# Blue evaluation: from starting evaluation to first validation, minus paused time
eval_end = t.red_validated_at or t.blue_validated_at
blue_secs = max(
0,
_seconds_between(t.blue_work_started_at, eval_end) - (t.blue_paused_seconds or 0),
)
total_red += red_secs
total_queue += queue_secs
total_blue += blue_secs
breakdown.append({
"test_id": str(t.id),
"test_name": t.name,
"state": t.state.value if t.state else None,
"red_execution_secs": red_secs,
"blue_queue_secs": queue_secs,
"blue_evaluation_secs": blue_secs,
"total_secs": red_secs + queue_secs + blue_secs,
"has_timing": bool(t.red_started_at),
})
total_secs = total_red + total_queue + total_blue
return {
"campaign_id": campaign_id,
"campaign_name": campaign.name,
"tests_total": len(tests),
"tests_with_timing": sum(1 for b in breakdown if b["has_timing"]),
"red_execution_secs": total_red,
"blue_queue_secs": total_queue,
"blue_evaluation_secs": total_blue,
"total_secs": total_secs,
"breakdown": sorted(breakdown, key=lambda x: -(x["total_secs"])),
}