"""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 # Import Optional from typing 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 # Import log_action from app.services.audit_service from app.services.audit_service import log_action # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( activate_campaign as crud_activate, ) # Import from app.services.campaign_crud_service 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, ) # Import from app.services.campaign_crud_service from app.services.campaign_crud_service import ( 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, ) # 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 generate_campaign_from_threat_actor from app.services.campaign_service from app.services.campaign_service import generate_campaign_from_threat_actor # Import notify_role from app.services.notification_service from app.services.notification_service import notify_role # Assign logger = logging.getLogger(__name__) logger = logging.getLogger(__name__) # Assign router = APIRouter(prefix="/campaigns", tags=["campaigns"]) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) # ── 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 # --------------------------------------------------------------------------- # 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), ) -> list: """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, ) # Call log_action() 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", # Keyword argument: entity_id entity_id=result["id"], # Keyword argument: details 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}/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 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, # Entry: db db: Session = Depends(get_db), # Entry: current_user current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ) -> dict: """Activate a campaign, moving it from draft to active. Args: campaign_id (str): UUID string of the campaign to activate. db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead or blue_lead activating the campaign. Returns: dict: Serialised representation of the activated campaign. """ # Open context manager 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) # Return serialize_campaign(db, campaign) 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) # 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 # --------------------------------------------------------------------------- @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, # Entry: db 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. """ # Assign campaign = generate_campaign_from_threat_actor( campaign = generate_campaign_from_threat_actor( db, uuid.UUID(actor_id), current_user, ) # 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)