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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user