From 4809c4a662ab677f571b13319d14504592595fc5 Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 16 Jul 2026 12:57:20 +0200 Subject: [PATCH] feat(manager): allow manager to create auto-approved campaigns and tests from templates 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. --- backend/app/routers/campaigns.py | 28 +++++++- backend/app/routers/tests.py | 2 +- backend/app/services/campaign_crud_service.py | 19 ++++- .../tests/test_campaign_approval_router.py | 71 ++++++++++++++++++- .../test_manager_creation_permissions.py | 56 +++++++++++++++ frontend/src/api/campaigns.ts | 3 + frontend/src/pages/CampaignsPage.tsx | 39 ++++++++-- frontend/src/pages/TestCatalogPage.tsx | 7 +- 8 files changed, 212 insertions(+), 13 deletions(-) create mode 100644 backend/tests/test_manager_creation_permissions.py diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index bfbdd17..8092e85 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -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 diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 8362a41..9e193df 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -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. diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index 4a9ac13..dbb2d41 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -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 diff --git a/backend/tests/test_campaign_approval_router.py b/backend/tests/test_campaign_approval_router.py index bdeaec2..9ce5551 100644 --- a/backend/tests/test_campaign_approval_router.py +++ b/backend/tests/test_campaign_approval_router.py @@ -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" diff --git a/backend/tests/test_manager_creation_permissions.py b/backend/tests/test_manager_creation_permissions.py new file mode 100644 index 0000000..a8ff5a7 --- /dev/null +++ b/backend/tests/test_manager_creation_permissions.py @@ -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 diff --git a/frontend/src/api/campaigns.ts b/frontend/src/api/campaigns.ts index 5968af9..952f3d3 100644 --- a/frontend/src/api/campaigns.ts +++ b/frontend/src/api/campaigns.ts @@ -73,6 +73,9 @@ export interface CampaignCreatePayload { target_platform?: string; tags?: string[]; scheduled_at?: string; + // Only honored when the creator is a manager — auto-approves the + // campaign immediately instead of queuing it for later approval. + start_date?: string; } export interface AddTestPayload { diff --git a/frontend/src/pages/CampaignsPage.tsx b/frontend/src/pages/CampaignsPage.tsx index 05d5e25..0c10bac 100644 --- a/frontend/src/pages/CampaignsPage.tsx +++ b/frontend/src/pages/CampaignsPage.tsx @@ -15,6 +15,7 @@ import { listCampaigns, createCampaign, generateCampaignFromThreatActor, type Ca import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors"; import { useAuth } from "../context/AuthContext"; import MarkdownText from "../components/MarkdownText"; +import { datetimeLocalToIso } from "../utils/datetime"; const statusColors: Record = { draft: "bg-gray-800/50 text-gray-400 border-gray-600/30", @@ -56,9 +57,18 @@ export default function CampaignsPage() { description: "", type: "custom", target_platform: "", + start_date: "", }); - const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead"; + const canCreate = + user?.role === "admin" || + user?.role === "red_lead" || + user?.role === "blue_lead" || + user?.role === "manager"; + // A manager's campaign is auto-approved on creation instead of going + // through the draft -> submit -> pending_approval queue, so they must + // pick the start_date up front rather than via a later /approve call. + const isManagerCreate = user?.role === "manager"; const { data, isLoading, error } = useQuery({ queryKey: ["campaigns", filters], @@ -71,11 +81,15 @@ export default function CampaignsPage() { }); const createMutation = useMutation({ - mutationFn: () => createCampaign(newCampaign), + mutationFn: () => + createCampaign({ + ...newCampaign, + start_date: isManagerCreate ? datetimeLocalToIso(newCampaign.start_date) : undefined, + }), onSuccess: (campaign) => { queryClient.invalidateQueries({ queryKey: ["campaigns"] }); setShowCreateForm(false); - setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" }); + setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" }); navigate(`/campaigns/${campaign.id}`); }, }); @@ -227,6 +241,23 @@ export default function CampaignsPage() { + {isManagerCreate && ( +
+ + setNewCampaign((c) => ({ ...c, start_date: e.target.value }))} + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none" + /> +

+ This campaign will be auto-approved and go active immediately — no manager review queue needed. +

+
+ )}