Files
Aegis/backend/app/services/campaign_scheduler_service.py

195 lines
6.1 KiB
Python

"""Campaign scheduler service — recurring campaign execution.
Handles checking which recurring campaigns are due, cloning them with
fresh tests, and computing the next run date.
"""
import logging
import uuid
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test
from app.models.enums import TestState
from app.services.notification_service import create_notification
from app.services.audit_service import log_action
from app.models.user import User
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Next-run calculation
# ---------------------------------------------------------------------------
def calculate_next_run(current_date: datetime, pattern: str) -> datetime:
"""Compute the next run date from *current_date* and a recurrence pattern.
Supported patterns:
- ``weekly`` : +7 days
- ``monthly`` : +30 days
- ``quarterly``: +90 days
"""
offsets = {
"weekly": timedelta(days=7),
"monthly": timedelta(days=30),
"quarterly": timedelta(days=90),
}
return current_date + offsets.get(pattern, timedelta(days=30))
# ---------------------------------------------------------------------------
# Clone a campaign
# ---------------------------------------------------------------------------
def _clone_campaign(db: Session, original: Campaign) -> Campaign:
"""Create a new child campaign from a recurring template.
1. Clone the campaign with a date-stamped name.
2. For each ``CampaignTest`` in the original, create a new ``Test``
with the same base data (in ``draft`` state) and link it.
3. Activate the new campaign.
"""
now = datetime.utcnow()
run_label = now.strftime("%Y-%m-%d")
child = Campaign(
name=f"{original.name} (Run {run_label})",
description=original.description,
type=original.type,
threat_actor_id=original.threat_actor_id,
status="active",
created_by=original.created_by,
target_platform=original.target_platform,
tags=original.tags or [],
parent_campaign_id=original.id,
)
db.add(child)
db.flush() # get child.id
# Clone each campaign_test with a fresh Test
original_cts = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == original.id)
.order_by(CampaignTest.order_index)
.all()
)
for ct in original_cts:
src_test = ct.test
if not src_test:
continue
new_test = Test(
technique_id=src_test.technique_id,
name=src_test.name,
description=src_test.description,
platform=src_test.platform,
procedure_text=src_test.procedure_text,
tool_used=src_test.tool_used,
created_by=original.created_by,
state=TestState.draft,
)
db.add(new_test)
db.flush() # get new_test.id
new_ct = CampaignTest(
campaign_id=child.id,
test_id=new_test.id,
order_index=ct.order_index,
phase=ct.phase,
# depends_on is not copied — would need ID remapping
)
db.add(new_ct)
db.flush()
return child
# ---------------------------------------------------------------------------
# Check and run recurring campaigns (daily job)
# ---------------------------------------------------------------------------
def check_and_run_recurring_campaigns(db: Session) -> int:
"""Check all recurring campaigns and clone any that are due.
Returns the number of campaigns spawned.
"""
now = datetime.utcnow()
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.is_recurring == True, # noqa: E712
Campaign.next_run_at <= now,
)
.all()
)
spawned = 0
for campaign in due_campaigns:
try:
child = _clone_campaign(db, campaign)
# Update the original's scheduling fields
campaign.last_run_at = now
campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly")
db.commit()
db.refresh(child)
# Audit
log_action(
db,
user_id=campaign.created_by,
action="recurring_campaign_run",
entity_type="campaign",
entity_id=child.id,
details={
"parent_campaign_id": str(campaign.id),
"child_campaign_name": child.name,
"pattern": campaign.recurrence_pattern,
},
)
db.commit()
# Notify
if campaign.created_by:
create_notification(
db,
user_id=campaign.created_by,
type="recurring_campaign_run",
title="Recurring campaign executed",
message=f'Campaign "{child.name}" was automatically created from recurring template "{campaign.name}".',
entity_type="campaign",
entity_id=child.id,
)
# Notify red_tech users
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
for user in red_techs:
create_notification(
db,
user_id=user.id,
type="campaign_activated",
title="New recurring campaign active",
message=f'Campaign "{child.name}" is now active and ready for execution.',
entity_type="campaign",
entity_id=child.id,
)
spawned += 1
logger.info("Spawned child campaign '%s' from parent '%s'", child.name, campaign.name)
except Exception:
db.rollback()
logger.exception("Failed to run recurring campaign '%s'", campaign.name)
return spawned