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)
|
tags: Optional[list[str]] = Field(default_factory=list)
|
||||||
# Assign scheduled_at = None
|
# Assign scheduled_at = None
|
||||||
scheduled_at: Optional[str] = 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
|
# Define class CampaignUpdate
|
||||||
@@ -314,18 +319,25 @@ def create_campaign(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# 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:
|
) -> dict:
|
||||||
"""Create a new campaign.
|
"""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:
|
Args:
|
||||||
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
payload (CampaignCreate): Fields for the new campaign (name, type, threat actor, etc.).
|
||||||
db (Session): SQLAlchemy database session.
|
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:
|
Returns:
|
||||||
dict: Serialised representation of the newly created campaign.
|
dict: Serialised representation of the newly created campaign.
|
||||||
"""
|
"""
|
||||||
|
is_manager_create = current_user.role == "manager"
|
||||||
# Open context manager
|
# Open context manager
|
||||||
with UnitOfWork(db) as uow:
|
with UnitOfWork(db) as uow:
|
||||||
# Assign result = crud_create(
|
# Assign result = crud_create(
|
||||||
@@ -347,6 +359,9 @@ def create_campaign(
|
|||||||
tags=payload.tags,
|
tags=payload.tags,
|
||||||
# Keyword argument: scheduled_at
|
# Keyword argument: scheduled_at
|
||||||
scheduled_at=payload.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"]
|
campaign_id = result["id"]
|
||||||
log_action(
|
log_action(
|
||||||
@@ -354,7 +369,7 @@ def create_campaign(
|
|||||||
# Keyword argument: user_id
|
# Keyword argument: user_id
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
# Keyword argument: action
|
# Keyword argument: action
|
||||||
action="create_campaign",
|
action="create_campaign_auto_approved" if is_manager_create else "create_campaign",
|
||||||
# Keyword argument: entity_type
|
# Keyword argument: entity_type
|
||||||
entity_type="campaign",
|
entity_type="campaign",
|
||||||
entity_id=campaign_id,
|
entity_id=campaign_id,
|
||||||
@@ -363,6 +378,13 @@ def create_campaign(
|
|||||||
# Call uow.commit()
|
# Call uow.commit()
|
||||||
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
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ def create_test_from_template(
|
|||||||
# Entry: db
|
# Entry: db
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
# Entry: current_user
|
# 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:
|
) -> TestOut:
|
||||||
"""Instantiate a real Test from an existing TestTemplate.
|
"""Instantiate a real Test from an existing TestTemplate.
|
||||||
|
|
||||||
|
|||||||
@@ -281,8 +281,20 @@ def create_campaign(
|
|||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
# Entry: scheduled_at
|
# Entry: scheduled_at
|
||||||
scheduled_at: Optional[str] = None,
|
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:
|
) -> 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(
|
# Assign campaign = Campaign(
|
||||||
campaign = Campaign(
|
campaign = Campaign(
|
||||||
# Keyword argument: name
|
# Keyword argument: name
|
||||||
@@ -302,6 +314,11 @@ def create_campaign(
|
|||||||
# Keyword argument: scheduled_at
|
# Keyword argument: scheduled_at
|
||||||
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
|
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
|
# Stage new record(s) for database insertion
|
||||||
db.add(campaign)
|
db.add(campaign)
|
||||||
# Flush changes to DB without committing the transaction
|
# 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"},
|
json={"name": "No date campaign", "start_date": "2026-01-01"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
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
|
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
|
||||||
@@ -73,6 +73,9 @@ export interface CampaignCreatePayload {
|
|||||||
target_platform?: string;
|
target_platform?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
scheduled_at?: 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 {
|
export interface AddTestPayload {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { listCampaigns, createCampaign, generateCampaignFromThreatActor, type Ca
|
|||||||
import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors";
|
import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import MarkdownText from "../components/MarkdownText";
|
import MarkdownText from "../components/MarkdownText";
|
||||||
|
import { datetimeLocalToIso } from "../utils/datetime";
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
|
||||||
@@ -56,9 +57,18 @@ export default function CampaignsPage() {
|
|||||||
description: "",
|
description: "",
|
||||||
type: "custom",
|
type: "custom",
|
||||||
target_platform: "",
|
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({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["campaigns", filters],
|
queryKey: ["campaigns", filters],
|
||||||
@@ -71,11 +81,15 @@ export default function CampaignsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createCampaign(newCampaign),
|
mutationFn: () =>
|
||||||
|
createCampaign({
|
||||||
|
...newCampaign,
|
||||||
|
start_date: isManagerCreate ? datetimeLocalToIso(newCampaign.start_date) : undefined,
|
||||||
|
}),
|
||||||
onSuccess: (campaign) => {
|
onSuccess: (campaign) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
|
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" });
|
||||||
navigate(`/campaigns/${campaign.id}`);
|
navigate(`/campaigns/${campaign.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -227,6 +241,23 @@ export default function CampaignsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -237,7 +268,7 @@ export default function CampaignsPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => createMutation.mutate()}
|
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"
|
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" />}
|
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
@@ -90,11 +90,12 @@ export default function TestCatalogPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Only leads can create tests from templates — admin administers the
|
// Leads and managers can create tests from templates — admin administers
|
||||||
// site, not test content.
|
// the site, not test content.
|
||||||
const canUseTemplate =
|
const canUseTemplate =
|
||||||
user?.role === "red_lead" ||
|
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
|
// Leads create templates straight into the catalog; operators get the
|
||||||
// same button but their submission goes through lead review first.
|
// same button but their submission goes through lead review first.
|
||||||
|
|||||||
Reference in New Issue
Block a user