feat(manager): allow manager to create auto-approved campaigns and tests from templates
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
A manager organizes and validates work, so their own campaigns skip the draft -> submit -> pending_approval queue and go straight to active with the start_date they provide (they're the same role that would otherwise approve it). Manager can also now create tests from the catalog, same as red_lead/blue_lead.
This commit is contained in:
@@ -171,6 +171,11 @@ class CampaignCreate(BaseModel):
|
||||
tags: Optional[list[str]] = Field(default_factory=list)
|
||||
# Assign scheduled_at = None
|
||||
scheduled_at: Optional[str] = None
|
||||
# Only honored when the creator is a manager — see create_campaign():
|
||||
# a manager's own campaign is auto-approved on creation instead of
|
||||
# going through the draft -> submit -> approve queue, so they need to
|
||||
# supply the start_date up front instead of via a later /approve call.
|
||||
start_date: Optional[str] = None
|
||||
|
||||
|
||||
# Define class CampaignUpdate
|
||||
@@ -314,18 +319,25 @@ def create_campaign(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
|
||||
) -> dict:
|
||||
"""Create a new campaign.
|
||||
|
||||
A manager's campaign is auto-approved on creation — a manager is the
|
||||
same role that would otherwise approve it, so routing it through the
|
||||
draft -> submit -> pending_approval queue would just mean approving
|
||||
their own submission. red_lead/blue_lead campaigns still go through
|
||||
that queue as before.
|
||||
|
||||
Args:
|
||||
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
||||
db (Session): SQLAlchemy database session.
|
||||
current_user (User): Authenticated red_lead or blue_lead creating the campaign.
|
||||
current_user (User): Authenticated red_lead, blue_lead, or manager creating the campaign.
|
||||
|
||||
Returns:
|
||||
dict: Serialised representation of the newly created campaign.
|
||||
"""
|
||||
is_manager_create = current_user.role == "manager"
|
||||
# Open context manager
|
||||
with UnitOfWork(db) as uow:
|
||||
# Assign result = crud_create(
|
||||
@@ -347,6 +359,9 @@ def create_campaign(
|
||||
tags=payload.tags,
|
||||
# Keyword argument: scheduled_at
|
||||
scheduled_at=payload.scheduled_at,
|
||||
auto_approve=is_manager_create,
|
||||
start_date=payload.start_date if is_manager_create else None,
|
||||
approver_id=current_user.id if is_manager_create else None,
|
||||
)
|
||||
campaign_id = result["id"]
|
||||
log_action(
|
||||
@@ -354,7 +369,7 @@ def create_campaign(
|
||||
# Keyword argument: user_id
|
||||
user_id=current_user.id,
|
||||
# Keyword argument: action
|
||||
action="create_campaign",
|
||||
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
|
||||
# Keyword argument: entity_type
|
||||
entity_type="campaign",
|
||||
entity_id=campaign_id,
|
||||
@@ -363,6 +378,13 @@ def create_campaign(
|
||||
# Call uow.commit()
|
||||
uow.commit()
|
||||
|
||||
if is_manager_create:
|
||||
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||
db.refresh(campaign)
|
||||
# Create Jira tickets now if the manager's chosen start_date is
|
||||
# already due — mirrors the normal manager /approve path.
|
||||
_create_jira_tickets_for_campaign(db, campaign, campaign_id, current_user)
|
||||
|
||||
# Return result
|
||||
return result
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ def create_test_from_template(
|
||||
# Entry: db
|
||||
db: Session = Depends(get_db),
|
||||
# Entry: current_user
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
|
||||
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
|
||||
) -> TestOut:
|
||||
"""Instantiate a real Test from an existing TestTemplate.
|
||||
|
||||
|
||||
@@ -281,8 +281,20 @@ def create_campaign(
|
||||
tags: Optional[list[str]] = None,
|
||||
# Entry: scheduled_at
|
||||
scheduled_at: Optional[str] = None,
|
||||
# A manager's own campaign is auto-approved on creation instead of
|
||||
# going through the draft -> submit -> pending_approval queue (a
|
||||
# manager is the same role that would otherwise approve it).
|
||||
auto_approve: bool = False,
|
||||
start_date: Optional[str] = None,
|
||||
approver_id: Optional[uuid.UUID] = None,
|
||||
) -> dict:
|
||||
"""Create a new campaign. Does not commit; caller commits."""
|
||||
"""Create a new campaign. Does not commit; caller commits.
|
||||
|
||||
Raises BusinessRuleViolation if auto_approve is set without a start_date.
|
||||
"""
|
||||
if auto_approve and not start_date:
|
||||
raise BusinessRuleViolation("start_date is required to auto-approve a campaign")
|
||||
|
||||
# Assign campaign = Campaign(
|
||||
campaign = Campaign(
|
||||
# Keyword argument: name
|
||||
@@ -302,6 +314,11 @@ def create_campaign(
|
||||
# Keyword argument: scheduled_at
|
||||
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
||||
)
|
||||
if auto_approve:
|
||||
campaign.start_date = datetime.fromisoformat(start_date)
|
||||
campaign.status = "active"
|
||||
campaign.approved_by = approver_id
|
||||
campaign.approved_at = datetime.utcnow()
|
||||
# Stage new record(s) for database insertion
|
||||
db.add(campaign)
|
||||
# Flush changes to DB without committing the transaction
|
||||
|
||||
@@ -346,5 +346,74 @@ def test_create_campaign_payload_has_no_start_date_field(api, db, red_lead_heade
|
||||
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
# start_date silently ignored — only the manager can ever set it, via /approve
|
||||
# start_date silently ignored for a lead — only a manager's own
|
||||
# creation (or a later /approve) can ever set it.
|
||||
assert resp.json()["start_date"] is None
|
||||
|
||||
|
||||
def test_manager_can_create_campaign_auto_approved(api, db, manager_headers):
|
||||
"""A manager is the same role that would otherwise approve a campaign,
|
||||
so their own campaign skips the draft -> submit -> pending_approval
|
||||
queue and goes straight to active with the start_date they provide."""
|
||||
resp = api(
|
||||
"post",
|
||||
"/api/v1/campaigns",
|
||||
manager_headers,
|
||||
json={"name": "Manager-created campaign", "start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "active"
|
||||
assert body["start_date"] is not None
|
||||
|
||||
|
||||
def test_manager_campaign_creation_requires_start_date(api, db, manager_headers):
|
||||
resp = api(
|
||||
"post",
|
||||
"/api/v1/campaigns",
|
||||
manager_headers,
|
||||
json={"name": "Manager-created campaign, no date"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_manager_creation_creates_jira_tickets_when_start_date_is_due(api, db, manager_headers):
|
||||
with patch(
|
||||
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||
) as mock_get_key, patch(
|
||||
"app.services.jira_service.auto_create_campaign_issue", return_value="PT-300"
|
||||
) as mock_create_campaign:
|
||||
resp = api(
|
||||
"post",
|
||||
"/api/v1/campaigns",
|
||||
manager_headers,
|
||||
json={"name": "Manager-created, due now", "start_date": "2020-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
mock_get_key.assert_called_once()
|
||||
mock_create_campaign.assert_called_once()
|
||||
|
||||
|
||||
def test_manager_creation_skips_jira_tickets_when_start_date_is_future(api, db, manager_headers):
|
||||
with patch(
|
||||
"app.services.jira_service.get_campaign_jira_key", return_value=None
|
||||
) as mock_get_key:
|
||||
resp = api(
|
||||
"post",
|
||||
"/api/v1/campaigns",
|
||||
manager_headers,
|
||||
json={"name": "Manager-created, future", "start_date": "2099-01-01T00:00:00"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
mock_get_key.assert_not_called()
|
||||
|
||||
|
||||
def test_lead_created_campaign_is_still_draft_not_auto_approved(api, db, red_lead_headers):
|
||||
resp = api(
|
||||
"post",
|
||||
"/api/v1/campaigns",
|
||||
red_lead_headers,
|
||||
json={"name": "Lead-created campaign"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["status"] == "draft"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Manager role — creating tests from templates.
|
||||
|
||||
A manager organizes and validates work rather than executing it, but they
|
||||
should still be able to seed a test from the catalog directly (same as a
|
||||
red_lead/blue_lead), not just approve/reject what leads submit.
|
||||
"""
|
||||
|
||||
|
||||
def test_manager_can_create_test_from_template(api, auth_headers, red_lead_headers, manager_headers):
|
||||
technique = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.400", "name": "Command Line Manager Test"},
|
||||
)
|
||||
assert technique.status_code == 201, technique.text
|
||||
technique_id = technique.json()["id"]
|
||||
|
||||
template = api(
|
||||
"post", "/api/v1/test-templates", red_lead_headers,
|
||||
json={
|
||||
"mitre_technique_id": "T1059.400",
|
||||
"name": "Manager-usable template",
|
||||
"attack_procedure": "Run a discovery command.",
|
||||
},
|
||||
)
|
||||
assert template.status_code == 201, template.text
|
||||
template_id = template.json()["id"]
|
||||
|
||||
resp = api(
|
||||
"post", "/api/v1/tests/from-template", manager_headers,
|
||||
json={"template_id": template_id, "technique_id": technique_id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["name"] == "Manager-usable template"
|
||||
|
||||
|
||||
def test_admin_still_cannot_create_test_from_template(api, red_lead_headers, auth_headers):
|
||||
"""require_any_role_strict keeps site-admin separate from workflow
|
||||
actions — adding manager to the allow-list must not accidentally
|
||||
reintroduce an admin bypass here."""
|
||||
technique = api(
|
||||
"post", "/api/v1/techniques", auth_headers,
|
||||
json={"mitre_id": "T1059.401", "name": "Command Line Admin Test"},
|
||||
)
|
||||
technique_id = technique.json()["id"]
|
||||
|
||||
template = api(
|
||||
"post", "/api/v1/test-templates", red_lead_headers,
|
||||
json={"mitre_technique_id": "T1059.401", "name": "Admin-blocked template"},
|
||||
)
|
||||
template_id = template.json()["id"]
|
||||
|
||||
resp = api(
|
||||
"post", "/api/v1/tests/from-template", auth_headers,
|
||||
json={"template_id": template_id, "technique_id": technique_id},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
Reference in New Issue
Block a user