feat(templates,campaigns): validate MITRE technique IDs, surface template suggestions first, manager can re-approve rejected campaigns
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
- TestTemplate/TemplateSuggestion creation and updates now reject any mitre_technique_id that doesn't match a real, already-synced MITRE ATT&CK technique. Frontend swaps the free-text ID input for a technique picker. - Test Catalog now shows pending template suggestions before the catalog grid, with full field detail (platform, severity, tool, atomic ID, source URL, remediation) instead of just name/procedure. - A manager can edit and directly re-approve a campaign they previously rejected, instead of needing the original lead to resubmit it.
This commit is contained in:
@@ -431,15 +431,19 @@ def update_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:
|
||||||
"""Update a campaign. Only allowed in draft or active state.
|
"""Update a campaign. Only allowed in draft or active state.
|
||||||
|
|
||||||
|
A manager may only edit a campaign that's sitting in draft with a
|
||||||
|
rejection_reason set (i.e. one they previously rejected) — see
|
||||||
|
``update_campaign()``'s ownership check.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
campaign_id (str): UUID string of the campaign to update.
|
campaign_id (str): UUID string of the campaign to update.
|
||||||
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
payload (CampaignUpdate): Partial update payload; only set fields are applied.
|
||||||
db (Session): SQLAlchemy database session.
|
db (Session): SQLAlchemy database session.
|
||||||
current_user (User): Authenticated red_lead or blue_lead performing the update.
|
current_user (User): Authenticated red_lead, blue_lead, or manager performing the update.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Serialised representation of the updated campaign.
|
dict: Serialised representation of the updated campaign.
|
||||||
|
|||||||
@@ -367,6 +367,11 @@ def approve_campaign(
|
|||||||
) -> Campaign:
|
) -> Campaign:
|
||||||
"""Approve a pending campaign: fix its start date and activate it.
|
"""Approve a pending campaign: fix its start date and activate it.
|
||||||
|
|
||||||
|
Also allows approving a previously-rejected campaign directly (status
|
||||||
|
``draft`` with a ``rejection_reason`` set) — a manager can edit it via
|
||||||
|
``update_campaign()`` and then approve it themselves, without needing
|
||||||
|
the original lead to resubmit it through the normal queue.
|
||||||
|
|
||||||
Raises EntityNotFoundError, BusinessRuleViolation.
|
Raises EntityNotFoundError, BusinessRuleViolation.
|
||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
@@ -374,8 +379,9 @@ def approve_campaign(
|
|||||||
if not campaign:
|
if not campaign:
|
||||||
raise EntityNotFoundError("Campaign", campaign_id)
|
raise EntityNotFoundError("Campaign", campaign_id)
|
||||||
|
|
||||||
if campaign.status != "pending_approval":
|
is_previously_rejected_draft = campaign.status == "draft" and campaign.rejection_reason
|
||||||
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
|
if campaign.status != "pending_approval" and not is_previously_rejected_draft:
|
||||||
|
raise BusinessRuleViolation("Only campaigns pending approval (or previously rejected) can be approved")
|
||||||
|
|
||||||
if not start_date:
|
if not start_date:
|
||||||
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
raise BusinessRuleViolation("start_date is required to approve a campaign")
|
||||||
@@ -454,7 +460,7 @@ def update_campaign(
|
|||||||
Does not commit; caller commits.
|
Does not commit; caller commits.
|
||||||
"""
|
"""
|
||||||
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
|
||||||
# Check: not campaign
|
# Check: not campaign
|
||||||
if not campaign:
|
if not campaign:
|
||||||
# Raise EntityNotFoundError
|
# Raise EntityNotFoundError
|
||||||
@@ -465,10 +471,16 @@ def update_campaign(
|
|||||||
# Raise BusinessRuleViolation
|
# Raise BusinessRuleViolation
|
||||||
raise BusinessRuleViolation("Can only update draft or active campaigns")
|
raise BusinessRuleViolation("Can only update draft or active campaigns")
|
||||||
|
|
||||||
# Check: str(campaign.created_by) != str(updater_id) and updater_role != "ad...
|
# A manager may edit a campaign they (or another manager) previously
|
||||||
if str(campaign.created_by) != str(updater_id) and updater_role != "admin":
|
# rejected — this is what lets them fix it up and self-approve it,
|
||||||
|
# instead of bouncing it back to the original lead to resubmit.
|
||||||
|
is_manager_editing_own_rejection = (
|
||||||
|
updater_role == "manager" and campaign.status == "draft" and campaign.rejection_reason
|
||||||
|
)
|
||||||
|
is_owner_or_admin = str(campaign.created_by) == str(updater_id) or updater_role == "admin"
|
||||||
|
if not is_owner_or_admin and not is_manager_editing_own_rejection:
|
||||||
# Raise PermissionViolation
|
# Raise PermissionViolation
|
||||||
raise PermissionViolation("Only the creator or admin can update this campaign")
|
raise PermissionViolation("Only the creator, admin, or a manager reviewing a rejected campaign can update this campaign")
|
||||||
|
|
||||||
# Check: "scheduled_at" in fields and fields["scheduled_at"]
|
# Check: "scheduled_at" in fields and fields["scheduled_at"]
|
||||||
if "scheduled_at" in fields and fields["scheduled_at"]:
|
if "scheduled_at" in fields and fields["scheduled_at"]:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.models.template_suggestion import TemplateSuggestion
|
|||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.notification_service import notify_role
|
from app.services.notification_service import notify_role
|
||||||
from app.services.test_template_service import create_template
|
from app.services.test_template_service import create_template, validate_mitre_technique_id
|
||||||
|
|
||||||
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
|
_TEAM_BY_ROLE = {"red_tech": "red", "blue_tech": "blue"}
|
||||||
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
_LEAD_ROLE = {"red": "red_lead", "blue": "blue_lead"}
|
||||||
@@ -45,6 +45,7 @@ def create_template_suggestion(db: Session, submitter: User, **fields: object) -
|
|||||||
Does not commit; caller uses UnitOfWork.
|
Does not commit; caller uses UnitOfWork.
|
||||||
"""
|
"""
|
||||||
team = team_for_submitter(submitter)
|
team = team_for_submitter(submitter)
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
|
suggestion = TemplateSuggestion(team=team, submitted_by=submitter.id, **fields)
|
||||||
db.add(suggestion)
|
db.add(suggestion)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy import func, or_
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
# Import EntityNotFoundError from app.domain.errors
|
# Import EntityNotFoundError from app.domain.errors
|
||||||
from app.domain.errors import EntityNotFoundError
|
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
|
||||||
|
|
||||||
# Import TestTemplate from app.models.test_template
|
# Import TestTemplate from app.models.test_template
|
||||||
from app.models.test_template import TestTemplate
|
from app.models.test_template import TestTemplate
|
||||||
@@ -235,9 +235,22 @@ def get_template_or_raise(db: Session, template_id: uuid.UUID) -> TestTemplate:
|
|||||||
return template
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mitre_technique_id(db: Session, mitre_technique_id: str) -> None:
|
||||||
|
"""Raise BusinessRuleViolation unless *mitre_technique_id* matches a real,
|
||||||
|
already-synced MITRE ATT&CK technique — free text like "made up" or a
|
||||||
|
typo'd ID would otherwise silently create an orphaned template that can
|
||||||
|
never be found via technique lookups or coverage reporting."""
|
||||||
|
exists = db.query(Technique).filter(Technique.mitre_id == mitre_technique_id).first()
|
||||||
|
if not exists:
|
||||||
|
raise BusinessRuleViolation(
|
||||||
|
f"'{mitre_technique_id}' is not a known MITRE ATT&CK technique ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Define function create_template
|
# Define function create_template
|
||||||
def create_template(db: Session, **fields: object) -> TestTemplate:
|
def create_template(db: Session, **fields: object) -> TestTemplate:
|
||||||
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
|
"""Create a test template from keyword args (e.g. payload.model_dump()). Does NOT commit."""
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
# Assign template = TestTemplate(**fields)
|
# Assign template = TestTemplate(**fields)
|
||||||
template = TestTemplate(**fields)
|
template = TestTemplate(**fields)
|
||||||
# Stage new record(s) for database insertion
|
# Stage new record(s) for database insertion
|
||||||
@@ -249,6 +262,8 @@ def create_template(db: Session, **fields: object) -> TestTemplate:
|
|||||||
# Define function update_template
|
# Define function update_template
|
||||||
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
|
def update_template(db: Session, template_id: uuid.UUID, **fields: object) -> TestTemplate:
|
||||||
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
|
"""Update an existing template. Raises EntityNotFoundError if not found. Does NOT commit."""
|
||||||
|
if fields.get("mitre_technique_id"):
|
||||||
|
validate_mitre_technique_id(db, fields["mitre_technique_id"])
|
||||||
# Assign template = get_template_or_raise(db, template_id)
|
# Assign template = get_template_or_raise(db, template_id)
|
||||||
template = get_template_or_raise(db, template_id)
|
template = get_template_or_raise(db, template_id)
|
||||||
# Iterate over fields.items()
|
# Iterate over fields.items()
|
||||||
|
|||||||
@@ -417,3 +417,56 @@ def test_lead_created_campaign_is_still_draft_not_auto_approved(api, db, red_lea
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 201, resp.text
|
assert resp.status_code == 201, resp.text
|
||||||
assert resp.json()["status"] == "draft"
|
assert resp.json()["status"] == "draft"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_edit_a_campaign_they_rejected(api, db, red_lead_user, red_lead_headers, manager_headers):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
reject = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
|
||||||
|
json={"reason": "Needs a clearer scope"},
|
||||||
|
)
|
||||||
|
assert reject.status_code == 200, reject.text
|
||||||
|
assert reject.json()["status"] == "draft"
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"name": "Edited by manager after rejection"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["name"] == "Edited by manager after rejection"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_edit_a_draft_campaign_that_was_never_rejected(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers,
|
||||||
|
):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"name": "Manager should not be able to do this"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_can_approve_a_campaign_they_previously_rejected(
|
||||||
|
api, db, red_lead_user, red_lead_headers, manager_headers,
|
||||||
|
):
|
||||||
|
campaign = _make_draft_campaign(db, red_lead_user.id)
|
||||||
|
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
|
||||||
|
api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/reject", manager_headers,
|
||||||
|
json={"reason": "Needs a clearer scope"},
|
||||||
|
)
|
||||||
|
api(
|
||||||
|
"patch", f"/api/v1/campaigns/{campaign.id}", manager_headers,
|
||||||
|
json={"description": "Scope clarified after rejection"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/campaigns/{campaign.id}/approve", manager_headers,
|
||||||
|
json={"start_date": "2020-01-01T00:00:00"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["rejection_reason"] is None
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""A TestTemplate/TemplateSuggestion's mitre_technique_id must correspond
|
||||||
|
to a real, already-synced MITRE ATT&CK technique — free text or a typo'd
|
||||||
|
ID would otherwise create an orphaned template invisible to technique
|
||||||
|
lookups and coverage reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def technique(api, auth_headers):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/techniques", auth_headers,
|
||||||
|
json={"mitre_id": "T1059.500", "name": "Command Line MITRE Validation"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateCreateValidation:
|
||||||
|
def test_create_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T9999.999", "name": "Bogus template"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "T9999.999" in resp.text
|
||||||
|
|
||||||
|
def test_create_template_accepts_known_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid template"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
def test_update_template_rejects_unknown_mitre_id(self, api, red_lead_headers, technique):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/test-templates", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Will be edited"},
|
||||||
|
)
|
||||||
|
template_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"patch", f"/api/v1/test-templates/{template_id}", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T0000.000", "name": "Will be edited"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateSuggestionValidation:
|
||||||
|
def test_propose_template_rejects_unknown_mitre_id(self, api, red_tech_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T8888.888", "name": "Bogus proposal"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_propose_template_accepts_known_mitre_id(self, api, red_tech_headers, technique):
|
||||||
|
resp = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
def test_approve_suggestion_with_edited_unknown_mitre_id_rejected(
|
||||||
|
self, api, red_tech_headers, red_lead_headers, technique,
|
||||||
|
):
|
||||||
|
created = api(
|
||||||
|
"post", "/api/v1/template-suggestions", red_tech_headers,
|
||||||
|
json={"mitre_technique_id": "T1059.500", "name": "Valid proposal"},
|
||||||
|
)
|
||||||
|
suggestion_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = api(
|
||||||
|
"post", f"/api/v1/template-suggestions/{suggestion_id}/approve", red_lead_headers,
|
||||||
|
json={"mitre_technique_id": "T7777.777"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { X, Loader2 } from "lucide-react";
|
import { X, Loader2 } from "lucide-react";
|
||||||
import { createTemplate, type CreateTemplatePayload } from "../api/test-templates";
|
import { createTemplate, type CreateTemplatePayload } from "../api/test-templates";
|
||||||
import { proposeTemplate } from "../api/template-suggestions";
|
import { proposeTemplate } from "../api/template-suggestions";
|
||||||
|
import { getTechniques, type TechniqueSummary } from "../api/techniques";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
|
const SEVERITY_OPTIONS = ["low", "medium", "high", "critical"];
|
||||||
@@ -22,6 +23,11 @@ export default function CreateTemplateModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const { data: techniques, isLoading: techniquesLoading } = useQuery({
|
||||||
|
queryKey: ["techniques"],
|
||||||
|
queryFn: () => getTechniques(),
|
||||||
|
});
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateTemplatePayload>({
|
const [form, setForm] = useState<CreateTemplatePayload>({
|
||||||
mitre_technique_id: "",
|
mitre_technique_id: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -91,15 +97,22 @@ export default function CreateTemplateModal({
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500">
|
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||||
MITRE Technique ID <span className="text-red-400">*</span>
|
MITRE Technique <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
required
|
required
|
||||||
|
disabled={techniquesLoading}
|
||||||
value={form.mitre_technique_id}
|
value={form.mitre_technique_id}
|
||||||
onChange={(e) => set("mitre_technique_id", e.target.value)}
|
onChange={(e) => set("mitre_technique_id", e.target.value)}
|
||||||
placeholder="T1059.001"
|
|
||||||
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"
|
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"
|
||||||
/>
|
>
|
||||||
|
<option value="">Select a technique</option>
|
||||||
|
{techniques?.map((tech: TechniqueSummary) => (
|
||||||
|
<option key={tech.id} value={tech.mitre_id}>
|
||||||
|
{tech.mitre_id} - {tech.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-gray-500">
|
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
submitCampaignForApproval,
|
submitCampaignForApproval,
|
||||||
approveCampaign,
|
approveCampaign,
|
||||||
rejectCampaign,
|
rejectCampaign,
|
||||||
|
updateCampaign,
|
||||||
listCampaignModificationRequests,
|
listCampaignModificationRequests,
|
||||||
approveModificationRequest,
|
approveModificationRequest,
|
||||||
rejectModificationRequest,
|
rejectModificationRequest,
|
||||||
@@ -84,6 +85,8 @@ export default function CampaignDetailPage() {
|
|||||||
const [approveStartDate, setApproveStartDate] = useState("");
|
const [approveStartDate, setApproveStartDate] = useState("");
|
||||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState("");
|
const [rejectReason, setRejectReason] = useState("");
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({ name: "", description: "" });
|
||||||
|
|
||||||
const showToast = (message: string, type: "success" | "error") => {
|
const showToast = (message: string, type: "success" | "error") => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
@@ -144,6 +147,16 @@ export default function CampaignDetailPage() {
|
|||||||
onError: (err: Error) => showToast(err.message, "error"),
|
onError: (err: Error) => showToast(err.message, "error"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editMutation = useMutation({
|
||||||
|
mutationFn: () => updateCampaign(campaignId!, editForm),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
|
||||||
|
setEditModalOpen(false);
|
||||||
|
showToast("Campaign updated", "success");
|
||||||
|
},
|
||||||
|
onError: (err: Error) => showToast(err.message, "error"),
|
||||||
|
});
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
mutationFn: () => completeCampaign(campaignId!),
|
mutationFn: () => completeCampaign(campaignId!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -287,6 +300,10 @@ export default function CampaignDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const progress = campaign.progress;
|
const progress = campaign.progress;
|
||||||
|
// A manager can edit and directly re-approve a campaign they (or another
|
||||||
|
// manager) previously rejected — no need to bounce it back to the
|
||||||
|
// original lead to resubmit it through the normal queue.
|
||||||
|
const isRejectedDraft = campaign.status === "draft" && !!campaign.rejection_reason;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -414,6 +431,26 @@ export default function CampaignDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isManager && isRejectedDraft && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditForm({ name: campaign.name, description: campaign.description || "" });
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-4 py-2 text-sm font-medium text-gray-300 hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setApproveModalOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-500 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{canComplete && campaign.status === "active" && (
|
{canComplete && campaign.status === "active" && (
|
||||||
<button
|
<button
|
||||||
onClick={() => completeMutation.mutate()}
|
onClick={() => completeMutation.mutate()}
|
||||||
@@ -852,6 +889,50 @@ export default function CampaignDetailPage() {
|
|||||||
onSuccess={() => showToast("Modification request submitted", "success")}
|
onSuccess={() => showToast("Modification request submitted", "success")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Edit modal (manager fixing up a campaign they rejected) */}
|
||||||
|
{editModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div className="mx-4 w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-2xl">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-white">Edit Campaign</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Name</label>
|
||||||
|
<input
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, name: 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.description}
|
||||||
|
onChange={(e) => setEditForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditModalOpen(false)}
|
||||||
|
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => editMutation.mutate()}
|
||||||
|
disabled={!editForm.name.trim() || editMutation.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"
|
||||||
|
>
|
||||||
|
{editMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Approve modal */}
|
{/* Approve modal */}
|
||||||
{approveModalOpen && (
|
{approveModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
|||||||
@@ -201,6 +201,14 @@ export default function TestCatalogPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Template suggestions for leads ────────────────────────────
|
||||||
|
Shown first, before the catalog itself — a lead's top priority
|
||||||
|
on this page is clearing the review queue, not browsing.
|
||||||
|
GET /template-suggestions is already scoped server-side to the
|
||||||
|
caller's own team (red_lead sees only red, blue_lead only blue;
|
||||||
|
admin sees both), so no client-side filtering is needed here. */}
|
||||||
|
{isReviewLead && <TemplateSuggestionsPanel />}
|
||||||
|
|
||||||
{/* Filters bar */}
|
{/* Filters bar */}
|
||||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-4">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
@@ -367,12 +375,6 @@ export default function TestCatalogPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Template suggestions for leads ────────────────────────────
|
|
||||||
GET /template-suggestions is already scoped server-side to the
|
|
||||||
caller's own team (red_lead sees only red, blue_lead only blue;
|
|
||||||
admin sees both), so no client-side filtering is needed here. */}
|
|
||||||
{isReviewLead && <TemplateSuggestionsPanel />}
|
|
||||||
|
|
||||||
{/* Template instantiation modal */}
|
{/* Template instantiation modal */}
|
||||||
{templateId && (
|
{templateId && (
|
||||||
<TestFromTemplateForm
|
<TestFromTemplateForm
|
||||||
@@ -441,7 +443,46 @@ function TemplateSuggestionsPanel() {
|
|||||||
{s.submitter_name && (
|
{s.submitter_name && (
|
||||||
<p className="mt-0.5 text-xs text-gray-500">Proposed by {s.submitter_name}</p>
|
<p className="mt-0.5 text-xs text-gray-500">Proposed by {s.submitter_name}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Metadata badges — everything a lead needs to judge the
|
||||||
|
proposal at a glance, not just the procedure text. */}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
|
||||||
|
{s.source}
|
||||||
|
</span>
|
||||||
|
{s.platform && (
|
||||||
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
|
||||||
|
{s.platform}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.severity && (
|
||||||
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs capitalize text-gray-400">
|
||||||
|
{s.severity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.tool_suggested && (
|
||||||
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs text-gray-400">
|
||||||
|
Tool: {s.tool_suggested}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.atomic_test_id && (
|
||||||
|
<span className="inline-flex rounded-full border border-gray-600/30 bg-gray-900 px-2 py-0.5 text-xs text-gray-400">
|
||||||
|
Atomic ID: {s.atomic_test_id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{s.description && <p className="mt-2 text-sm text-gray-400">{s.description}</p>}
|
{s.description && <p className="mt-2 text-sm text-gray-400">{s.description}</p>}
|
||||||
|
{s.source_url && (
|
||||||
|
<a
|
||||||
|
href={s.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="mt-1 inline-block text-xs text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
{s.source_url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{s.attack_procedure && (
|
{s.attack_procedure && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
|
<p className="text-xs font-medium uppercase text-gray-500">Suggested Attack Procedure</p>
|
||||||
@@ -458,6 +499,14 @@ function TemplateSuggestionsPanel() {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{s.suggested_remediation && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xs font-medium uppercase text-gray-500">Suggested Remediation</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap rounded bg-gray-900 p-2 font-mono text-xs text-gray-300">
|
||||||
|
{s.suggested_remediation}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => approveMutation.mutate(s.id)}
|
onClick={() => approveMutation.mutate(s.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user