"""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, require_any_role_strict # 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, notify_roles_by_email 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 # Only honored when the creator is a manager — see create_campaign(): # a manager's own campaign is auto-approved on creation instead of # going through the draft -> submit -> approve queue, so they need to # supply the start_date up front instead of via a later /approve call. start_date: 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 # Strict variant — admin must NOT get a free pass here. Admin # administers the site, not campaign content; the only admin path to # an active campaign is the emergency /activate override below. current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")), ) -> dict: """Create a new campaign. A manager's campaign is auto-approved on creation — a manager is the same role that would otherwise approve it, so routing it through the draft -> submit -> pending_approval queue would just mean approving their own submission. red_lead/blue_lead campaigns still go through that queue as before. Args: payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.). db (Session): SQLAlchemy database session. current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign. Returns: dict: Serialised representation of the newly created campaign. """ is_manager_create = current_user.role == "manager" # 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, auto_approve=is_manager_create, start_date=payload.start_date if is_manager_create else None, approver_id=current_user.id if is_manager_create else None, ) campaign_id = result["id"] log_action( db, # Keyword argument: user_id user_id=current_user.id, # Keyword argument: action action="create_campaign_auto_approved" if is_manager_create else "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() if is_manager_create: campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first() db.refresh(campaign) # Create Jira tickets now if the manager's chosen start_date is # already due — mirrors the normal manager /approve path. _create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user) # 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", "manager")), ) -> dict: """Update a campaign. Only allowed in draft or active state. A manager may only edit a campaign that's sitting in draft with a rejection_reason set (i.e. one they previously rejected) — see ``update_campaign()``'s ownership check. 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, blue_lead, or manager 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}, ) notify_roles_by_email( db, roles=["red_tech"], preference_key="email_on_assigned_to_campaign", subject=f"Campaign Activated: {campaign.name}", message=f'Campaign "{campaign.name}" has been approved and activated. You may have tests assigned.', ) 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, ) notify_roles_by_email( db, roles=["red_tech"], preference_key="email_on_assigned_to_campaign", subject=f"Campaign Activated: {campaign.name}", message=f'Campaign "{campaign.name}" has been activated. You may have tests assigned.', ) # 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 # Strict variant — admin must NOT get a free pass here, same as the # plain create_campaign endpoint above. current_user: User = Depends(require_any_role_strict("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"])), }