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

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:
kitos
2026-07-16 12:57:20 +02:00
parent c753797019
commit 4809c4a662
8 changed files with 212 additions and 13 deletions
+25 -3
View File
@@ -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
+1 -1
View File
@@ -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.
+18 -1
View File
@@ -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
+70 -1
View File
@@ -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
+3
View File
@@ -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 {
+35 -4
View File
@@ -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<string, string> = {
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() {
</select>
</div>
</div>
{isManagerCreate && (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Start Date <span className="text-red-400">*</span>
</label>
<input
type="datetime-local"
required
value={newCampaign.start_date}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-gray-500">
This campaign will be auto-approved and go active immediately no manager review queue needed.
</p>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<button
@@ -237,7 +268,7 @@ export default function CampaignsPage() {
</button>
<button
onClick={() => createMutation.mutate()}
disabled={!newCampaign.name || createMutation.isPending}
disabled={!newCampaign.name || (isManagerCreate && !newCampaign.start_date) || createMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
+4 -3
View File
@@ -90,11 +90,12 @@ export default function TestCatalogPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { user } = useAuth();
// Only leads can create tests from templates — admin administers the
// site, not test content.
// Leads and managers can create tests from templates — admin administers
// the site, not test content.
const canUseTemplate =
user?.role === "red_lead" ||
user?.role === "blue_lead";
user?.role === "blue_lead" ||
user?.role === "manager";
// Leads create templates straight into the catalog; operators get the
// same button but their submission goes through lead review first.