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

- 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:
kitos
2026-07-16 11:09:10 +02:00
parent 0b60531677
commit cf4a6c3cde
9 changed files with 291 additions and 101 deletions
@@ -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
+31
View File
@@ -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,