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