From c62dafbc1f5d588ac70141aabba5f2756adf965f Mon Sep 17 00:00:00 2001 From: kitos Date: Wed, 3 Jun 2026 16:57:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(campaigns):=20campaign=20start=20date=20?= =?UTF-8?q?=E2=80=94=20scheduled=20activation,=20Jira=20start=5Fdate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB: migration b047 adds start_date (DateTime nullable) + index to campaigns. Backend: - Campaign model: start_date field - CampaignCreate/Update schemas: accept start_date (ISO string) - CRUD service: persist + serialize start_date in both serializers - Activation endpoint: blocks manual activation if start_date is in the future (campaign will auto-activate via scheduler) - Scheduler: new hourly job _run_scheduled_campaign_activation — finds draft campaigns with start_date <= now, activates them, creates Jira tickets, notifies red_tech team - Jira: campaign + test tickets now include JIRA_START_DATE_FIELD (configurable, default customfield_10015). Campaign uses start_date if set, else created_at. Tests inherit campaign start_date. - config.py: JIRA_START_DATE_FIELD setting Frontend: - Campaign type: start_date field on Campaign + CampaignSummary - CampaignCreatePayload: start_date optional field - Create form: date picker with min=today, warning message explaining behavior - Campaign detail header: start_date badge showing days remaining or started date Co-Authored-By: Claude Sonnet 4.6 --- .../versions/b047_campaign_start_date.py | 27 +++++ backend/app/config.py | 3 + backend/app/jobs/mitre_sync_job.py | 98 +++++++++++++++++++ backend/app/models/campaign.py | 1 + backend/app/routers/campaigns.py | 26 ++++- backend/app/services/campaign_crud_service.py | 6 ++ backend/app/services/jira_service.py | 12 +++ frontend/src/api/campaigns.ts | 3 + frontend/src/pages/CampaignDetailPage.tsx | 18 ++++ frontend/src/pages/CampaignsPage.tsx | 26 ++++- 10 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/b047_campaign_start_date.py diff --git a/backend/alembic/versions/b047_campaign_start_date.py b/backend/alembic/versions/b047_campaign_start_date.py new file mode 100644 index 0000000..02d511c --- /dev/null +++ b/backend/alembic/versions/b047_campaign_start_date.py @@ -0,0 +1,27 @@ +"""Add start_date to campaigns. + +Revision ID: b047 +Revises: b046 +Create Date: 2026-06-03 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "b047" +down_revision = "b046" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "campaigns", + sa.Column("start_date", sa.DateTime(), nullable=True), + ) + op.create_index("ix_campaigns_start_date", "campaigns", ["start_date"]) + + +def downgrade() -> None: + op.drop_index("ix_campaigns_start_date", table_name="campaigns") + op.drop_column("campaigns", "start_date") diff --git a/backend/app/config.py b/backend/app/config.py index a5a270d..6951912 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -57,6 +57,9 @@ class Settings(BaseSettings): JIRA_DEFAULT_PROJECT: str = "" JIRA_ISSUE_TYPE_TEST: str = "Task" # tests (campaign or standalone) JIRA_ISSUE_TYPE_CAMPAIGN: str = "Epic" # campaigns (under Initiative) + # Jira custom field ID for "Start date" — Jira Cloud team-managed: customfield_10015 + # Override with the correct field ID for your Jira instance if different. + JIRA_START_DATE_FIELD: str = "customfield_10015" # ── Tempo Integration ───────────────────────────────────────────── TEMPO_ENABLED: bool = False diff --git a/backend/app/jobs/mitre_sync_job.py b/backend/app/jobs/mitre_sync_job.py index 0dbb844..0da99e4 100644 --- a/backend/app/jobs/mitre_sync_job.py +++ b/backend/app/jobs/mitre_sync_job.py @@ -101,6 +101,96 @@ def _run_recurring_campaigns() -> None: db.close() +def _run_scheduled_campaign_activation() -> None: + """Auto-activate campaigns whose start_date has arrived. + + Finds all campaigns in 'draft' state with a start_date <= now, + activates them, creates Jira tickets, and notifies the red_tech team. + Runs every hour so campaigns activate within ~1 hour of their scheduled time. + """ + logger.info("Scheduled campaign auto-activation check starting...") + db = SessionLocal() + try: + from datetime import datetime as _dt + from app.models.campaign import Campaign + from app.models.user import User + from app.services.campaign_crud_service import activate_campaign as _activate + from app.services.notification_service import notify_role + from app.services.audit_service import log_action + + now = _dt.utcnow() + due_campaigns = ( + db.query(Campaign) + .filter( + Campaign.status == "draft", + Campaign.start_date != None, # noqa: E711 + Campaign.start_date <= now, + ) + .all() + ) + + activated = 0 + for campaign in due_campaigns: + try: + _activate(db, str(campaign.id)) + notify_role( + db, + role="red_tech", + type="campaign_activated", + title="Campaign auto-activated", + message=f'Campaign "{campaign.name}" has been automatically activated on its scheduled start date.', + entity_type="campaign", + entity_id=campaign.id, + ) + log_action( + db, + user_id=None, + action="auto_activate_campaign", + entity_type="campaign", + entity_id=campaign.id, + details={"name": campaign.name, "start_date": str(campaign.start_date)}, + ) + + # Create Jira tickets non-fatally + try: + from app.services.jira_service import ( + auto_create_campaign_issue, + auto_create_test_issue, + get_campaign_jira_key, + get_test_jira_key, + ) + # Use first admin user as actor for Jira auth + admin_user = db.query(User).filter(User.role == "admin").first() + if admin_user: + db.refresh(campaign) + campaign_jira_key = get_campaign_jira_key(db, str(campaign.id)) + if not campaign_jira_key: + campaign_jira_key = auto_create_campaign_issue(db, campaign, admin_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, admin_user, + parent_ticket_override=campaign_jira_key, + campaign_start_date=campaign.start_date, + ) + except Exception: + logger.exception("Jira auto-create failed for auto-activated campaign %s", campaign.id) + + db.commit() + activated += 1 + logger.info("Auto-activated campaign %s (%s)", campaign.id, campaign.name) + except Exception: + logger.exception("Failed to auto-activate campaign %s", campaign.id) + db.rollback() + + logger.info("Campaign auto-activation check finished — activated %d campaigns", activated) + except Exception: + logger.exception("Campaign auto-activation job failed") + finally: + db.close() + + def _run_intel_scan() -> None: """Execute an intel scan inside its own DB session.""" logger.info("Scheduled intel scan job starting...") @@ -291,6 +381,14 @@ def start_scheduler() -> None: name="Weekly coverage snapshot (Sundays 00:00)", replace_existing=True, ) + scheduler.add_job( + _run_scheduled_campaign_activation, + trigger="interval", + hours=1, + id="scheduled_campaign_activation", + name="Auto-activate campaigns on start_date (hourly)", + replace_existing=True, + ) scheduler.add_job( _run_recurring_campaigns, trigger="interval", diff --git a/backend/app/models/campaign.py b/backend/app/models/campaign.py index 464972f..957f8f2 100644 --- a/backend/app/models/campaign.py +++ b/backend/app/models/campaign.py @@ -48,6 +48,7 @@ class Campaign(Base): ForeignKey("users.id", ondelete="SET NULL"), nullable=True, ) + start_date = Column(DateTime, nullable=True) # campaign won't activate before this date scheduled_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) target_platform = Column(String, nullable=True) diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 0549cd7..669e712 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -54,6 +54,7 @@ class CampaignCreate(BaseModel): target_platform: Optional[str] = None tags: Optional[list[str]] = Field(default_factory=list) scheduled_at: Optional[str] = None + start_date: Optional[str] = None # ISO date — campaign won't activate before this class CampaignUpdate(BaseModel): name: Optional[str] = None @@ -62,6 +63,7 @@ class CampaignUpdate(BaseModel): target_platform: Optional[str] = None tags: Optional[list[str]] = None scheduled_at: Optional[str] = None + start_date: Optional[str] = None # ISO date — can be updated while still in draft class AddTestPayload(BaseModel): test_id: str @@ -125,6 +127,7 @@ def create_campaign( target_platform=payload.target_platform, tags=payload.tags, scheduled_at=payload.scheduled_at, + start_date=payload.start_date, ) campaign_id = result["id"] log_action( @@ -273,7 +276,27 @@ def activate_campaign( db: Session = Depends(get_db), current_user: User = Depends(require_any_role("red_lead", "blue_lead")), ): - """Activate a campaign, moving it from draft to active.""" + """Activate a campaign, moving it from draft to active. + + If the campaign has a start_date in the future, manual activation is blocked — + the campaign will be auto-activated by the scheduler when the date arrives. + """ + # Guard: start_date must have been reached before manual activation + campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first() + if campaign_obj and campaign_obj.start_date: + now = datetime.utcnow() + if campaign_obj.start_date > now: + from fastapi import HTTPException + raise HTTPException( + status_code=422, + detail=( + f"This campaign is scheduled to start on " + f"{campaign_obj.start_date.strftime('%Y-%m-%d')}. " + f"It will be activated automatically on that date. " + f"To activate it now, remove the start date first." + ), + ) + with UnitOfWork(db) as uow: campaign = crud_activate(db, campaign_id) notify_role( @@ -314,6 +337,7 @@ def activate_campaign( auto_create_test_issue( db, ct.test, current_user, parent_ticket_override=campaign_jira_key, + campaign_start_date=campaign.start_date, ) db.commit() except Exception: diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index 25102b7..f350aba 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -72,6 +72,7 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict: "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None, "threat_actor_name": actor.name if actor else None, "created_by": str(campaign.created_by) if campaign.created_by else None, + "start_date": campaign.start_date.isoformat() if campaign.start_date else None, "scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None, "completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None, "target_platform": campaign.target_platform, @@ -100,6 +101,7 @@ def serialize_campaign_summary(db: Session, campaign: Campaign) -> dict: "status": campaign.status, "threat_actor_id": str(campaign.threat_actor_id) if campaign.threat_actor_id else None, "threat_actor_name": actor.name if actor else None, + "start_date": campaign.start_date.isoformat() if campaign.start_date else None, "target_platform": campaign.target_platform, "tags": campaign.tags or [], "created_at": campaign.created_at.isoformat() if campaign.created_at else None, @@ -160,6 +162,7 @@ def create_campaign( target_platform: Optional[str] = None, tags: Optional[list[str]] = None, scheduled_at: Optional[str] = None, + start_date: Optional[str] = None, ) -> dict: """Create a new campaign. Does not commit; caller commits.""" campaign = Campaign( @@ -171,6 +174,7 @@ def create_campaign( tags=tags or [], created_by=creator_id, scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None, + start_date=datetime.fromisoformat(start_date) if start_date else None, ) db.add(campaign) db.flush() @@ -213,6 +217,8 @@ def update_campaign( if "scheduled_at" in fields and fields["scheduled_at"]: fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"]) + if "start_date" in fields and fields["start_date"]: + fields["start_date"] = datetime.fromisoformat(fields["start_date"]) for field, value in fields.items(): setattr(campaign, field, value) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 4c3d55f..fe1819e 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -407,6 +407,11 @@ def auto_create_campaign_issue( "customfield_10011": campaign.name, } + # Set start date: use campaign.start_date if set, otherwise today + effective_start = campaign.start_date or campaign.created_at + if effective_start: + fields[settings.JIRA_START_DATE_FIELD] = effective_start.strftime("%Y-%m-%d") + # Nest under the configured parent ticket (Initiative, e.g. OFS-20795) if parent_ticket: fields["parent"] = {"key": parent_ticket} @@ -446,6 +451,7 @@ def auto_create_test_issue( *, technique: Optional[Technique] = None, parent_ticket_override: Optional[str] = None, + campaign_start_date=None, # datetime | None — inherited from campaign when available ) -> Optional[str]: """Create a Jira issue for *test* and store the link. @@ -496,6 +502,12 @@ def auto_create_test_issue( "customfield_10309": f"{{code}}{poc}{{code}}", } + # Inherit campaign start date if available, otherwise use today + from datetime import date as _date + effective_start = campaign_start_date or _date.today() + if hasattr(effective_start, "strftime"): + fields[settings.JIRA_START_DATE_FIELD] = effective_start.strftime("%Y-%m-%d") + if parent: fields["parent"] = {"key": parent} diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index 0c63ab0..69d200b 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -31,6 +31,7 @@ export interface Campaign { threat_actor_id: string | null; threat_actor_name: string | null; created_by: string | null; + start_date: string | null; scheduled_at: string | null; completed_at: string | null; target_platform: string | null; @@ -55,6 +56,7 @@ export interface CampaignSummary { threat_actor_name: string | null; target_platform: string | null; tags: string[]; + start_date: string | null; created_at: string | null; test_count: number; completion_pct: number; @@ -63,6 +65,7 @@ export interface CampaignSummary { export interface CampaignCreatePayload { name: string; description?: string; + start_date?: string; // ISO date YYYY-MM-DD — campaign won't activate before this type?: string; threat_actor_id?: string; target_platform?: string; diff --git a/frontend/src/pages/CampaignDetailPage.tsx b/frontend/src/pages/CampaignDetailPage.tsx index 6c5ced3..4f5b9c9 100644 --- a/frontend/src/pages/CampaignDetailPage.tsx +++ b/frontend/src/pages/CampaignDetailPage.tsx @@ -254,6 +254,24 @@ export default function CampaignDetailPage() { Created {formatDate(campaign.created_at)} + {campaign.start_date && (() => { + const sd = new Date(campaign.start_date); + const now = new Date(); + const isPast = sd <= now; + const diffDays = Math.ceil((sd.getTime() - now.getTime()) / 86400000); + return ( + + + {isPast + ? `Started ${formatDate(campaign.start_date)}` + : `Starts ${formatDate(campaign.start_date)} (${diffDays}d)`} + + ); + })()} {campaign.completed_at && ( diff --git a/frontend/src/pages/CampaignsPage.tsx b/frontend/src/pages/CampaignsPage.tsx index a5a481a..8a204e2 100644 --- a/frontend/src/pages/CampaignsPage.tsx +++ b/frontend/src/pages/CampaignsPage.tsx @@ -54,6 +54,7 @@ export default function CampaignsPage() { description: "", type: "custom", target_platform: "", + start_date: "", }); const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead"; @@ -73,7 +74,7 @@ export default function CampaignsPage() { onSuccess: (campaign) => { queryClient.invalidateQueries({ queryKey: ["campaigns"] }); setShowCreateForm(false); - setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" }); + setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" }); navigate(`/campaigns/${campaign.id}`); }, }); @@ -224,6 +225,29 @@ export default function CampaignsPage() { + + {/* Start date */} +
+ + setNewCampaign((c) => ({ ...c, start_date: e.target.value }))} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none [color-scheme:dark]" + /> + {newCampaign.start_date && ( +

+ + Tests won't be queued until {new Date(newCampaign.start_date + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}. Manual activation before that date is blocked. +

+ )} +