feat(campaigns): submit/approve/reject router endpoints
This commit is contained in:
@@ -91,6 +91,16 @@ from app.services.campaign_crud_service import (
|
|||||||
activate_campaign as crud_activate,
|
activate_campaign as crud_activate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
submit_campaign_for_approval as crud_submit,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
approve_campaign as crud_approve,
|
||||||
|
)
|
||||||
|
from app.services.campaign_crud_service import (
|
||||||
|
reject_campaign as crud_reject,
|
||||||
|
)
|
||||||
|
|
||||||
# Import log_action from app.services.audit_service
|
# Import log_action from app.services.audit_service
|
||||||
from app.services.audit_service import log_action
|
from app.services.audit_service import log_action
|
||||||
|
|
||||||
@@ -172,6 +182,18 @@ class SchedulePayload(BaseModel):
|
|||||||
next_run_at: Optional[str] = None
|
next_run_at: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovePayload(BaseModel):
|
||||||
|
"""Payload for a manager approving a pending campaign."""
|
||||||
|
|
||||||
|
start_date: str # ISO date/datetime — required, campaign won't activate without it
|
||||||
|
|
||||||
|
|
||||||
|
class RejectPayload(BaseModel):
|
||||||
|
"""Payload for a manager rejecting a pending campaign."""
|
||||||
|
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /campaigns — List campaigns with filters
|
# GET /campaigns — List campaigns with filters
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -385,6 +407,98 @@ def update_campaign(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/submit — Submit draft for manager approval
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/submit")
|
||||||
|
def submit_campaign(
|
||||||
|
campaign_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||||
|
) -> dict:
|
||||||
|
"""Submit a draft campaign into the manager's approval queue."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_submit(
|
||||||
|
db, campaign_id,
|
||||||
|
submitter_id=current_user.id,
|
||||||
|
submitter_role=current_user.role,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="submit_campaign_for_approval",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"name": campaign.name},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/approve — Manager approves a pending campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/approve")
|
||||||
|
def approve_campaign_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: ApprovePayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager approves a pending campaign, fixing its start date and activating it."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_approve(
|
||||||
|
db, campaign_id,
|
||||||
|
approver_id=current_user.id,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="approve_campaign",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"start_date": payload.start_date},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/reject")
|
||||||
|
def reject_campaign_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: RejectPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_any_role("manager")),
|
||||||
|
) -> dict:
|
||||||
|
"""Manager rejects a pending campaign, returning it to draft with a reason."""
|
||||||
|
with UnitOfWork(db) as uow:
|
||||||
|
campaign = crud_reject(
|
||||||
|
db, campaign_id,
|
||||||
|
rejecter_id=current_user.id,
|
||||||
|
reason=payload.reason,
|
||||||
|
)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="reject_campaign",
|
||||||
|
entity_type="campaign",
|
||||||
|
entity_id=campaign.id,
|
||||||
|
details={"reason": payload.reason},
|
||||||
|
)
|
||||||
|
uow.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return serialize_campaign(db, campaign)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DELETE /campaigns/{id} — Delete campaign
|
# DELETE /campaigns/{id} — Delete campaign
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict:
|
|||||||
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
|
||||||
|
"approved_by": str(campaign.approved_by) if campaign.approved_by else None,
|
||||||
|
"approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None,
|
||||||
|
"rejection_reason": campaign.rejection_reason,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
"target_platform": campaign.target_platform,
|
"target_platform": campaign.target_platform,
|
||||||
# Literal argument value
|
# Literal argument value
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Router-level tests for the campaign manager-approval workflow."""
|
||||||
|
|
||||||
|
from app.models.campaign import Campaign, CampaignTest
|
||||||
|
from app.models.technique import Technique
|
||||||
|
from app.models.test import Test
|
||||||
|
from app.models.enums import TestState
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, url, headers, **kwargs):
|
||||||
|
"""POST while forcing auth via the Authorization header.
|
||||||
|
|
||||||
|
The login endpoint also sets an HttpOnly ``aegis_token`` cookie, and the
|
||||||
|
shared ``TestClient`` cookie jar persists across requests within a test.
|
||||||
|
``get_current_user`` prefers the cookie over the ``Authorization``
|
||||||
|
header, so once a second role's headers fixture logs in (setting its own
|
||||||
|
cookie), a request made with an *earlier* role's headers would silently
|
||||||
|
authenticate as the later role unless the leftover cookie is cleared.
|
||||||
|
"""
|
||||||
|
client.cookies.clear()
|
||||||
|
return client.post(url, headers=headers, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_draft_campaign(db, owner_id):
|
||||||
|
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
|
||||||
|
db.add(tech)
|
||||||
|
db.flush()
|
||||||
|
campaign = Campaign(name="Router Test Campaign", type="custom", status="draft", created_by=owner_id)
|
||||||
|
db.add(campaign)
|
||||||
|
db.flush()
|
||||||
|
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=owner_id)
|
||||||
|
db.add(test)
|
||||||
|
db.flush()
|
||||||
|
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
|
||||||
|
db.commit()
|
||||||
|
db.refresh(campaign)
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_can_submit_own_campaign(client, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "pending_approval"
|
||||||
|
|
||||||
|
|
||||||
|
def test_red_tech_cannot_submit(client, db, red_lead_user, red_tech_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = _post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_tech_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_approve_and_sets_start_date(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = _post(
|
||||||
|
client,
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
manager_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["start_date"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_cannot_approve(client, db, red_lead_user, red_lead_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = _post(
|
||||||
|
client,
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
red_lead_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_approve_as_manager_backup(client, db, red_lead_user, red_lead_headers, auth_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = _post(
|
||||||
|
client,
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/approve",
|
||||||
|
auth_headers,
|
||||||
|
json={"start_date": "2026-09-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_reject_with_reason(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = _post(
|
||||||
|
client,
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/reject",
|
||||||
|
manager_headers,
|
||||||
|
json={"reason": "Needs more detail"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_reject_without_reason_rejected_by_validation(client, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
_post(client, f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
|
||||||
|
resp = _post(
|
||||||
|
client,
|
||||||
|
f"/api/v1/campaigns/{campaign.id}/reject",
|
||||||
|
manager_headers,
|
||||||
|
json={"reason": ""},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
Reference in New Issue
Block a user