fix(campaigns): defer Jira ticket creation to start_date, gate recurring campaigns behind manager approval
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
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
- 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.
This commit is contained in:
@@ -27,7 +27,10 @@ from app.jobs.jira_sync_job import sync_all_jira_links
|
||||
from app.jobs.retention_job import run_retention_job
|
||||
|
||||
# Import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service
|
||||
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
|
||||
from app.services.campaign_scheduler_service import (
|
||||
check_and_run_recurring_campaigns,
|
||||
sync_due_campaign_jira_tickets,
|
||||
)
|
||||
|
||||
# Import scan_intel from app.services.intel_service
|
||||
from app.services.intel_service import scan_intel
|
||||
@@ -164,6 +167,19 @@ def _run_recurring_campaigns() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_due_campaign_jira_sync() -> None:
|
||||
"""Create Jira tickets for campaigns whose scheduled start_date has arrived."""
|
||||
logger.info("Scheduled due-campaign Jira sync starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
logger.info("Due-campaign Jira sync finished — processed %d campaigns", processed)
|
||||
except Exception:
|
||||
logger.exception("Due-campaign Jira sync failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_intel_scan() -> None:
|
||||
"""Execute an intel scan inside its own DB session."""
|
||||
# Log info: "Scheduled intel scan job starting..."
|
||||
@@ -497,6 +513,14 @@ def start_scheduler() -> None:
|
||||
# Keyword argument: replace_existing
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_due_campaign_jira_sync,
|
||||
trigger="interval",
|
||||
minutes=15,
|
||||
id="due_campaign_jira_sync",
|
||||
name="Due campaign Jira ticket sync (every 15 min)",
|
||||
replace_existing=True,
|
||||
)
|
||||
# Call scheduler.add_job()
|
||||
scheduler.add_job(
|
||||
sync_all_jira_links,
|
||||
@@ -605,7 +629,7 @@ def start_scheduler() -> None:
|
||||
# Literal argument value
|
||||
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
|
||||
# Literal argument value
|
||||
"recurring_campaigns (daily), jira_sync (1h), "
|
||||
"recurring_campaigns (daily), due_campaign_jira_sync (15m), jira_sync (1h), "
|
||||
# Literal argument value
|
||||
"osint_enrichment (weekly), stale_detection (daily), "
|
||||
"retention_policies (daily), data_sources_sync (6h), "
|
||||
|
||||
@@ -135,39 +135,21 @@ 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 a campaign and its already-linked tests, if missing.
|
||||
"""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.
|
||||
Tests may already be linked to the campaign from when it was still a
|
||||
draft, i.e. before any Jira ticket existed for the campaign — so they
|
||||
need their own tickets created here too. Best-effort: failures are
|
||||
logged, not raised, since a Jira outage must not block activation.
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
from app.services.jira_service import (
|
||||
auto_create_campaign_issue,
|
||||
auto_create_test_issue,
|
||||
get_campaign_jira_key,
|
||||
get_test_jira_key,
|
||||
)
|
||||
campaign_jira_key = get_campaign_jira_key(db, campaign_id)
|
||||
if not campaign_jira_key:
|
||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||
if campaign_jira_key:
|
||||
for ct in campaign.campaign_tests:
|
||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||
auto_create_test_issue(
|
||||
db, ct.test, user,
|
||||
parent_ticket_override=campaign_jira_key,
|
||||
campaign_start_date=campaign.start_date,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira ticket creation failed while activating campaign %s",
|
||||
campaign_id,
|
||||
)
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -72,32 +72,24 @@ def _clone_campaign(db: Session, original: Campaign) -> Campaign:
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
# Assign now = datetime.utcnow()
|
||||
now = datetime.utcnow()
|
||||
# Assign run_label = now.strftime("%Y-%m-%d")
|
||||
run_label = now.strftime("%Y-%m-%d")
|
||||
|
||||
# Assign child = Campaign(
|
||||
child = Campaign(
|
||||
# Keyword argument: name
|
||||
name=f"{original.name} (Run {run_label})",
|
||||
# Keyword argument: description
|
||||
description=original.description,
|
||||
# Keyword argument: type
|
||||
type=original.type,
|
||||
# Keyword argument: threat_actor_id
|
||||
threat_actor_id=original.threat_actor_id,
|
||||
# Keyword argument: status
|
||||
status="active",
|
||||
# Keyword argument: created_by
|
||||
status="pending_approval",
|
||||
created_by=original.created_by,
|
||||
# Keyword argument: target_platform
|
||||
target_platform=original.target_platform,
|
||||
# Keyword argument: tags
|
||||
tags=original.tags or [],
|
||||
# Keyword argument: parent_campaign_id
|
||||
parent_campaign_id=original.id,
|
||||
)
|
||||
# Stage new record(s) for database insertion
|
||||
@@ -239,46 +231,34 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
||||
# Commit all pending changes to the database
|
||||
db.commit()
|
||||
|
||||
# Notify
|
||||
# Notify the creator — the run happened, but it still needs a
|
||||
# manager's approval before it goes anywhere.
|
||||
if campaign.created_by:
|
||||
# Call create_notification()
|
||||
create_notification(
|
||||
db,
|
||||
# Keyword argument: user_id
|
||||
user_id=campaign.created_by,
|
||||
# Keyword argument: type
|
||||
type="recurring_campaign_run",
|
||||
# Keyword argument: title
|
||||
title="Recurring campaign executed",
|
||||
# Keyword argument: message
|
||||
title="Recurring campaign created — awaiting approval",
|
||||
message=(
|
||||
f'Campaign "{child.name}" was automatically created '
|
||||
f'from recurring template "{campaign.name}".'
|
||||
f'from recurring template "{campaign.name}" and is now '
|
||||
f'queued for manager approval.'
|
||||
),
|
||||
# Keyword argument: entity_type
|
||||
entity_type="campaign",
|
||||
# Keyword argument: entity_id
|
||||
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
|
||||
# Iterate over red_techs
|
||||
for user in red_techs:
|
||||
# Call create_notification()
|
||||
# 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,
|
||||
# Keyword argument: user_id
|
||||
user_id=user.id,
|
||||
# Keyword argument: type
|
||||
type="campaign_activated",
|
||||
# Keyword argument: title
|
||||
title="New recurring campaign active",
|
||||
# Keyword argument: message
|
||||
message=f'Campaign "{child.name}" is now active and ready for execution.',
|
||||
# Keyword argument: entity_type
|
||||
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",
|
||||
# Keyword argument: entity_id
|
||||
entity_id=child.id,
|
||||
)
|
||||
|
||||
@@ -296,3 +276,61 @@ def check_and_run_recurring_campaigns(db: Session) -> int:
|
||||
|
||||
# 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
|
||||
|
||||
@@ -705,6 +705,37 @@ def auto_create_campaign_issue(
|
||||
return None
|
||||
|
||||
|
||||
def ensure_campaign_jira_tickets(db: Session, campaign, user: User) -> None:
|
||||
"""Create Jira tickets for a campaign and its already-linked tests, if missing.
|
||||
|
||||
Called from two places: immediately at manager-approval time when the
|
||||
campaign's ``start_date`` is now/past, and from the periodic
|
||||
``sync_due_campaign_jira_tickets`` job for campaigns approved with a
|
||||
future ``start_date`` once that date actually arrives. Idempotent —
|
||||
checks for existing links before creating anything — so it is safe to
|
||||
call repeatedly. Best-effort: failures are logged, not raised, since a
|
||||
Jira outage must not block campaign activation.
|
||||
"""
|
||||
try:
|
||||
campaign_jira_key = get_campaign_jira_key(db, campaign.id)
|
||||
if not campaign_jira_key:
|
||||
campaign_jira_key = auto_create_campaign_issue(db, campaign, user)
|
||||
if campaign_jira_key:
|
||||
for ct in campaign.campaign_tests:
|
||||
if ct.test and not get_test_jira_key(db, ct.test.id):
|
||||
auto_create_test_issue(
|
||||
db, ct.test, user,
|
||||
parent_ticket_override=campaign_jira_key,
|
||||
campaign_start_date=campaign.start_date,
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Jira ticket creation failed for campaign %s",
|
||||
campaign.id,
|
||||
)
|
||||
|
||||
|
||||
def auto_create_test_issue(
|
||||
db: Session,
|
||||
test: Test,
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
@@ -53,10 +53,13 @@ def test_manager_can_approve_and_sets_start_date(api, db, red_lead_user, red_lea
|
||||
assert body["start_date"] is not None
|
||||
|
||||
|
||||
def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||
def test_manager_approval_creates_jira_tickets_when_start_date_is_due(
|
||||
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||
):
|
||||
"""The normal manager-approval path must create Jira tickets too, not just
|
||||
the admin-only emergency /activate override — this was the Block 1 gap:
|
||||
campaigns approved through the standard flow never got a Jira ticket."""
|
||||
campaigns approved through the standard flow never got a Jira ticket.
|
||||
Uses a past start_date so ticket creation is due immediately."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
@@ -68,7 +71,7 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2026-09-01T00:00:00"},
|
||||
json={"start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -77,6 +80,29 @@ def test_manager_approval_creates_jira_tickets(api, db, red_lead_user, red_lead_
|
||||
mock_create_test.assert_called_once()
|
||||
|
||||
|
||||
def test_manager_approval_skips_jira_tickets_when_start_date_is_future(
|
||||
api, db, red_lead_user, red_lead_headers, manager_headers
|
||||
):
|
||||
"""A campaign scheduled to start in the future must not get its Jira
|
||||
tickets created at approval time — that must wait for the periodic
|
||||
due-campaign sync job, once the scheduled start_date actually arrives."""
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
with patch("app.services.jira_service.get_campaign_jira_key", return_value=None) as mock_get_key, \
|
||||
patch("app.services.jira_service.auto_create_campaign_issue", return_value="PT-100") as mock_create_campaign:
|
||||
resp = api(
|
||||
"post",
|
||||
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||
manager_headers,
|
||||
json={"start_date": "2099-01-01T00:00:00"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_get_key.assert_not_called()
|
||||
mock_create_campaign.assert_not_called()
|
||||
|
||||
|
||||
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
|
||||
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.services.campaign_service import (
|
||||
from app.services.campaign_scheduler_service import (
|
||||
calculate_next_run,
|
||||
check_and_run_recurring_campaigns,
|
||||
sync_due_campaign_jira_tickets,
|
||||
)
|
||||
from app.services.snapshot_service import (
|
||||
create_snapshot,
|
||||
@@ -199,7 +200,10 @@ class TestCampaigns:
|
||||
)
|
||||
assert child is not None
|
||||
assert "Run" in child.name
|
||||
assert child.status == "active"
|
||||
# Recurrence only automates spawning the run — it still needs a
|
||||
# manager's approval like any other campaign, same as the manual
|
||||
# draft -> submit -> approve path.
|
||||
assert child.status == "pending_approval"
|
||||
|
||||
# Check child tests are fresh copies (new IDs, draft state)
|
||||
child_cts = (
|
||||
@@ -217,12 +221,92 @@ class TestCampaigns:
|
||||
test = db.query(Test).filter(Test.id == ct.test_id).first()
|
||||
assert test.state == TestState.draft
|
||||
|
||||
def test_campaign_cloning_notifies_managers(self, db, campaign_with_tests, manager_user):
|
||||
"""Managers must hear about a spawned recurring run — it's sitting in
|
||||
their approval queue just like a manually-submitted campaign."""
|
||||
from app.models.notification import Notification
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.is_recurring = True
|
||||
campaign.recurrence_pattern = "monthly"
|
||||
campaign.next_run_at = datetime.utcnow() - timedelta(hours=1)
|
||||
db.commit()
|
||||
|
||||
check_and_run_recurring_campaigns(db)
|
||||
|
||||
notif = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.user_id == manager_user.id, Notification.type == "campaign_pending_approval")
|
||||
.first()
|
||||
)
|
||||
assert notif is not None
|
||||
|
||||
# Check parent was updated
|
||||
db.refresh(campaign)
|
||||
assert campaign.last_run_at is not None
|
||||
assert campaign.next_run_at > datetime.utcnow()
|
||||
|
||||
|
||||
class TestDueCampaignJiraSync:
|
||||
"""sync_due_campaign_jira_tickets — the periodic catch-up job for
|
||||
campaigns approved with a future start_date whose Jira tickets were
|
||||
deliberately skipped at approval time."""
|
||||
|
||||
def test_creates_tickets_for_due_campaign_without_jira_link(self, db, campaign_with_tests, admin_user):
|
||||
from unittest.mock import patch
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() - timedelta(hours=1) # now due
|
||||
db.commit()
|
||||
|
||||
with patch(
|
||||
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||
) as mock_get_key, patch(
|
||||
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-200"
|
||||
) as mock_create_campaign, patch(
|
||||
"app.services.jira_service.get_test_jira_key", return_value=None
|
||||
), patch(
|
||||
"app.services.jira_service.auto_create_test_issue"
|
||||
) as mock_create_test:
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
|
||||
assert processed == 1
|
||||
mock_get_key.assert_called_once()
|
||||
mock_create_campaign.assert_called_once()
|
||||
assert mock_create_test.call_count == len(campaign_with_tests["tests"])
|
||||
|
||||
def test_skips_campaign_with_future_start_date(self, db, campaign_with_tests, admin_user):
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() + timedelta(days=30)
|
||||
db.commit()
|
||||
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
assert processed == 0
|
||||
|
||||
def test_skips_campaign_already_linked_to_jira(self, db, campaign_with_tests, admin_user):
|
||||
from app.models.jira_link import JiraLink, JiraLinkEntityType
|
||||
|
||||
campaign = campaign_with_tests["campaign"]
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = admin_user.id
|
||||
campaign.start_date = datetime.utcnow() - timedelta(hours=1)
|
||||
db.add(JiraLink(
|
||||
entity_type=JiraLinkEntityType.campaign,
|
||||
entity_id=campaign.id,
|
||||
jira_issue_key="PT-1",
|
||||
jira_project_key="PT",
|
||||
created_by=admin_user.id,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
processed = sync_due_campaign_jira_tickets(db)
|
||||
assert processed == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Snapshot Tests
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user