diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index 79dc38b..a152592 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -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), " diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 04cda06..bfbdd17 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -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 ───────────────────────────────────────────────── diff --git a/backend/app/services/campaign_scheduler_service.py b/backend/app/services/campaign_scheduler_service.py index ed21179..dac30a6 100644 --- a/backend/app/services/campaign_scheduler_service.py +++ b/backend/app/services/campaign_scheduler_service.py @@ -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 diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 1599b7e..83fcaf0 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -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, diff --git a/backend/tests/test_campaign_approval_router.py b/backend/tests/test_campaign_approval_router.py index 83c1527..bdeaec2 100644 --- a/backend/tests/test_campaign_approval_router.py +++ b/backend/tests/test_campaign_approval_router.py @@ -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) diff --git a/backend/tests/test_campaigns_and_snapshots.py b/backend/tests/test_campaigns_and_snapshots.py index 421f43e..1c9c0a5 100644 --- a/backend/tests/test_campaigns_and_snapshots.py +++ b/backend/tests/test_campaigns_and_snapshots.py @@ -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 # ═══════════════════════════════════════════════════════════════════════ diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index a9d7138..b1829d5 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -41,6 +41,7 @@ import JiraLinkPanel from "../components/JiraLinkPanel"; import CampaignTimingPanel from "../components/CampaignTimingPanel"; import AddTestToCampaignModal from "../components/AddTestToCampaignModal"; import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal"; +import { datetimeLocalToIso } from "../utils/datetime"; const statusColors: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", @@ -115,7 +116,12 @@ export default function CampaignDetailPage() { }); const approveMutation = useMutation({ - mutationFn: () => approveCampaign(campaignId!, approveStartDate), + // approveStartDate comes from — wall-clock + // time in the browser's own timezone. Sending it raw let the backend + // (which treats it as UTC) silently shift the campaign's real start + // time by the browser's UTC offset, causing Jira tickets to be created + // before the date/time the manager actually picked. + mutationFn: () => approveCampaign(campaignId!, datetimeLocalToIso(approveStartDate)!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] }); queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] }); diff --git a/frontend/src/pages/TestDetailPage.tsx b/frontend/src/pages/TestDetailPage.tsx index ac80d00..ec6fda6 100644 --- a/frontend/src/pages/TestDetailPage.tsx +++ b/frontend/src/pages/TestDetailPage.tsx @@ -43,32 +43,7 @@ import ConfirmDialog from "../components/ConfirmDialog"; import JiraLinkPanel from "../components/JiraLinkPanel"; import TestPhaseTimeline from "../components/TestPhaseTimeline"; import { createTemplate } from "../api/test-templates"; - -// ── Helpers ──────────────────────────────────────────────────────── - -// The backend always stores/returns naive UTC datetimes (no "Z", no -// offset). shows/edits wall-clock time in -// the browser's own timezone, so both directions need an explicit -// UTC <-> local conversion — treating the raw digits as interchangeable -// silently shifts every value by the browser's UTC offset. -function isoToDatetimeLocal(iso: string | null): string { - if (!iso) return ""; - const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z"; - const d = new Date(utcStr); - if (isNaN(d.getTime())) return ""; - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - -function datetimeLocalToIso(local: string): string | undefined { - if (!local) return undefined; - // New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator) - // parses it as local time per the ECMA-262 date string spec — exactly - // the wall-clock time the user picked. - const d = new Date(local); - if (isNaN(d.getTime())) return undefined; - return d.toISOString(); -} +import { isoToDatetimeLocal, datetimeLocalToIso } from "../utils/datetime"; // ── Page Component ───────────────────────────────────────────────── diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts new file mode 100644 index 0000000..733d09b --- /dev/null +++ b/frontend/src/utils/datetime.ts @@ -0,0 +1,24 @@ +// The backend always stores/returns naive UTC datetimes (no "Z", no +// offset). shows/edits wall-clock time in +// the browser's own timezone, so both directions need an explicit +// UTC <-> local conversion — treating the raw digits as interchangeable +// silently shifts every value by the browser's UTC offset. + +export function isoToDatetimeLocal(iso: string | null): string { + if (!iso) return ""; + const utcStr = iso.endsWith("Z") || iso.includes("+") ? iso : iso + "Z"; + const d = new Date(utcStr); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function datetimeLocalToIso(local: string): string | undefined { + if (!local) return undefined; + // New Date() on a plain "YYYY-MM-DDTHH:mm" string (no zone designator) + // parses it as local time per the ECMA-262 date string spec — exactly + // the wall-clock time the user picked. + const d = new Date(local); + if (isNaN(d.getTime())) return undefined; + return d.toISOString(); +}