Files
Aegis/backend/app/services/campaign_scheduler_service.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

337 lines
12 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 logging
# Import datetime, timedelta from datetime
from datetime import datetime, timedelta
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
# Import Campaign, CampaignTest from app.models.campaign
from app.models.campaign import Campaign, CampaignTest
# Import TestState from app.models.enums
from app.models.enums import TestState
# Import Test from app.models.test
from app.models.test import Test
# 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 create_notification from app.services.notification_service
from app.services.notification_service import create_notification
# Assign logger = logging.getLogger(__name__)
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
"""
# Assign offsets = {
offsets = {
# Literal argument value
"weekly": timedelta(days=7),
# Literal argument value
"monthly": timedelta(days=30),
# Literal argument value
"quarterly": timedelta(days=90),
}
# Return current_date + offsets.get(pattern, timedelta(days=30))
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. Queue the new campaign for manager approval — recurrence only
automates *creating* the run, not skipping the same approval gate
every other campaign goes through. A manager still picks the real
start date (and that's what triggers Jira ticket creation) via the
normal /approve endpoint.
"""
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="pending_approval",
created_by=original.created_by,
target_platform=original.target_platform,
tags=original.tags or [],
parent_campaign_id=original.id,
)
# Stage new record(s) for database insertion
db.add(child)
# Flush changes to DB without committing the transaction
db.flush() # get child.id
# Clone each campaign_test with a fresh Test
original_cts = (
db.query(CampaignTest)
# Chain .filter() call
.filter(CampaignTest.campaign_id == original.id)
# Chain .order_by() call
.order_by(CampaignTest.order_index)
# Chain .all() call
.all()
)
# Iterate over original_cts
for ct in original_cts:
# Assign src_test = ct.test
src_test = ct.test
# Check: not src_test
if not src_test:
# Skip to the next loop iteration
continue
# Assign new_test = Test(
new_test = Test(
# Keyword argument: technique_id
technique_id=src_test.technique_id,
# Keyword argument: name
name=src_test.name,
# Keyword argument: description
description=src_test.description,
# Keyword argument: platform
platform=src_test.platform,
# Keyword argument: procedure_text
procedure_text=src_test.procedure_text,
# Keyword argument: tool_used
tool_used=src_test.tool_used,
# Keyword argument: created_by
created_by=original.created_by,
# Keyword argument: state
state=TestState.draft,
)
# Stage new record(s) for database insertion
db.add(new_test)
# Flush changes to DB without committing the transaction
db.flush() # get new_test.id
# Assign new_ct = CampaignTest(
new_ct = CampaignTest(
# Keyword argument: campaign_id
campaign_id=child.id,
# Keyword argument: test_id
test_id=new_test.id,
# Keyword argument: order_index
order_index=ct.order_index,
# Keyword argument: phase
phase=ct.phase,
# depends_on is not copied — would need ID remapping
)
# Stage new record(s) for database insertion
db.add(new_ct)
# Flush changes to DB without committing the transaction
db.flush()
# Return child
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.
"""
# Assign now = datetime.utcnow()
now = datetime.utcnow()
# Assign due_campaigns = (
due_campaigns = (
db.query(Campaign)
# Chain .filter() call
.filter(
Campaign.is_recurring == True, # noqa: E712
Campaign.next_run_at <= now,
)
# Chain .all() call
.all()
)
# Assign spawned = 0
spawned = 0
# Iterate over due_campaigns
for campaign in due_campaigns:
# Attempt the following; catch errors below
try:
# Assign child = _clone_campaign(db, campaign)
child = _clone_campaign(db, campaign)
# Update the original's scheduling fields
campaign.last_run_at = now
# Assign campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly")
campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly")
# Commit all pending changes to the database
db.commit()
# Reload ORM object attributes from the database
db.refresh(child)
# Audit
log_action(
db,
# Keyword argument: user_id
user_id=campaign.created_by,
# Keyword argument: action
action="recurring_campaign_run",
# Keyword argument: entity_type
entity_type="campaign",
# Keyword argument: entity_id
entity_id=child.id,
# Keyword argument: details
details={
# Literal argument value
"parent_campaign_id": str(campaign.id),
# Literal argument value
"child_campaign_name": child.name,
# Literal argument value
"pattern": campaign.recurrence_pattern,
},
)
# Commit all pending changes to the database
db.commit()
# Notify the creator — the run happened, but it still needs a
# manager's approval before it goes anywhere.
if campaign.created_by:
create_notification(
db,
user_id=campaign.created_by,
type="recurring_campaign_run",
title="Recurring campaign created — awaiting approval",
message=(
f'Campaign "{child.name}" was automatically created '
f'from recurring template "{campaign.name}" and is now '
f'queued for manager approval.'
),
entity_type="campaign",
entity_id=child.id,
)
# Notify managers — same approval gate as any other campaign,
# recurrence only automates spawning the run, not skipping review.
managers = db.query(User).filter(User.role == "manager", User.is_active == True).all() # noqa: E712
for user in managers:
create_notification(
db,
user_id=user.id,
type="campaign_pending_approval",
title="Recurring campaign needs approval",
message=f'Campaign "{child.name}" was auto-created from a recurring template and needs your approval.',
entity_type="campaign",
entity_id=child.id,
)
# Assign spawned = 1
spawned += 1
# Log info: "Spawned child campaign '%s' from parent '%s'", ch
logger.info("Spawned child campaign '%s' from parent '%s'", child.name, campaign.name)
# Handle Exception
except Exception:
# Roll back all uncommitted changes
db.rollback()
# Log exception: "Failed to run recurring campaign '%s'", campaign.
logger.exception("Failed to run recurring campaign '%s'", campaign.name)
# Return spawned
return spawned
# ---------------------------------------------------------------------------
# Catch up on due campaigns' Jira tickets (periodic job)
# ---------------------------------------------------------------------------
def sync_due_campaign_jira_tickets(db: Session) -> int:
"""Create Jira tickets for active campaigns whose start_date has arrived.
A campaign approved with a future ``start_date`` intentionally skips
Jira ticket creation at approval time (see
``app.routers.campaigns._create_jira_tickets_for_campaign``). This job
is what actually creates those tickets once that date arrives — it
finds active campaigns lacking a Jira link and, for any whose
start_date is now due, calls the same idempotent creation helper.
Returns the number of campaigns processed.
"""
from app.models.jira_link import JiraLink, JiraLinkEntityType
from app.services.jira_service import ensure_campaign_jira_tickets
now = datetime.utcnow()
linked_campaign_ids = {
row[0]
for row in db.query(JiraLink.entity_id).filter(
JiraLink.entity_type == JiraLinkEntityType.campaign
).all()
}
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.status == "active",
Campaign.start_date.isnot(None),
Campaign.start_date <= now,
)
.all()
)
processed = 0
for campaign in due_campaigns:
if campaign.id in linked_campaign_ids:
continue
actor = db.query(User).filter(User.id == campaign.approved_by).first()
if not actor:
actor = db.query(User).filter(User.id == campaign.created_by).first()
if not actor:
logger.warning(
"Cannot sync Jira tickets for campaign %s: no valid actor found",
campaign.id,
)
continue
ensure_campaign_jira_tickets(db, campaign, actor)
processed += 1
return processed