fix(campaigns,tests): admin cannot create campaigns, manager can delete unstarted tests, intensify red/blue team colors
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

- Campaign creation and generate-from-threat-actor now use the strict
  role check — admin no longer gets a free pass into campaign content,
  same principle already applied to test-template creation.
- New manager-only DELETE /tests/{id}: removes a standalone test still
  in draft (not started, not linked to any campaign).
- Replaced the orange/indigo stand-ins used across team badges, tabs,
  action buttons, and timers with true red/blue so Red Team and Blue
  Team read unambiguously at a glance.
This commit is contained in:
kitos
2026-07-17 14:34:14 +02:00
parent 82ac9c7014
commit 60ab2e558f
25 changed files with 273 additions and 91 deletions
+8 -3
View File
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from app.database import get_db
# Import get_current_user, require_any_role from app.dependencies.auth
from app.dependencies.auth import get_current_user, require_any_role
from app.dependencies.auth import get_current_user, require_any_role, require_any_role_strict
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
@@ -319,7 +319,10 @@ def create_campaign(
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead", "manager")),
# Strict variant — admin must NOT get a free pass here. Admin
# administers the site, not campaign content; the only admin path to
# an active campaign is the emergency /activate override below.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead", "manager")),
) -> dict:
"""Create a new campaign.
@@ -987,7 +990,9 @@ def generate_campaign_from_actor(
payload: GenerateFromActorPayload = GenerateFromActorPayload(),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# Strict variant — admin must NOT get a free pass here, same as the
# plain create_campaign endpoint above.
current_user: User = Depends(require_any_role_strict("red_lead", "blue_lead")),
) -> dict:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
+37
View File
@@ -94,6 +94,11 @@ from app.services.test_crud_service import (
create_test_from_template as crud_create_from_template,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
delete_test as crud_delete_test,
)
# Import from app.services.test_crud_service
from app.services.test_crud_service import (
get_test_detail as crud_get_test_detail,
@@ -543,6 +548,38 @@ def update_test(
return test
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
# manager-only, and admin does NOT get a free pass — this is a
# queue-cleanup action for the manager role specifically, not a site
# administration task.
current_user: User = Depends(require_any_role_strict("manager")),
) -> None:
"""Delete a standalone test that hasn't started yet.
Only tests still in ``draft`` state and not linked to any campaign can
be deleted this way.
Args:
test_id (uuid.UUID): Primary key of the test to delete.
db (Session): SQLAlchemy database session.
current_user (User): Authenticated manager performing the deletion.
"""
with UnitOfWork(db) as uow:
crud_delete_test(db, test_id)
log_action(
db,
user_id=current_user.id,
action="delete_test",
entity_type="test",
entity_id=test_id,
details={},
)
uow.commit()
# ---------------------------------------------------------------------------
# PATCH /tests/{id}/classification — admin data classification
# ---------------------------------------------------------------------------
+22
View File
@@ -438,6 +438,28 @@ def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
return test
def delete_test(db: Session, test_id: uuid.UUID) -> None:
"""Delete a standalone, not-yet-started test from the queue.
Only a test still in ``draft`` (execution hasn't started) and not
linked to any campaign may be deleted this way a test already being
worked on, or one that's part of a campaign's plan, must be handled
through the normal workflow/campaign-modification paths instead.
Raises EntityNotFoundError, BusinessRuleViolation. Does not commit;
caller commits.
"""
test = get_test_or_raise(db, test_id)
if test.state != TestState.draft:
raise BusinessRuleViolation("Only tests that haven't started yet can be deleted")
in_campaign = db.query(CampaignTest).filter(CampaignTest.test_id == test.id).first()
if in_campaign:
raise BusinessRuleViolation("Cannot delete a test that belongs to a campaign")
db.delete(test)
# Define function get_test_with_technique
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
@@ -39,6 +39,8 @@ DUMMY_ID = str(uuid.uuid4())
("post", "/api/v1/tests", {"technique_id": DUMMY_ID, "name": "x"}),
("post", "/api/v1/tests/from-template", {"template_id": DUMMY_ID, "technique_id": DUMMY_ID}),
("post", "/api/v1/tests/import-rt", {"name": "x", "techniques": []}),
("post", "/api/v1/campaigns", {"name": "x"}),
("post", f"/api/v1/campaigns/from-threat-actor/{DUMMY_ID}", None),
],
)
def test_admin_forbidden(client, auth_headers, method, path, payload):
+73
View File
@@ -0,0 +1,73 @@
"""A manager (and only a manager) can delete a standalone test from the
queue, but only while it hasn't started yet (draft) and isn't linked to
any campaign a test already in progress, or one that's part of a
campaign's plan, must go through the normal workflow instead.
"""
import pytest
@pytest.fixture
def technique(api, auth_headers):
resp = api(
"post", "/api/v1/techniques", auth_headers,
json={"mitre_id": "T1059.600", "name": "Command Line Delete Test"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.fixture
def draft_test(api, red_lead_headers, technique):
resp = api(
"post", "/api/v1/tests", red_lead_headers,
json={"technique_id": technique, "name": "Standalone draft test"},
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
def test_manager_can_delete_draft_standalone_test(api, manager_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 204, resp.text
reloaded = api("get", f"/api/v1/tests/{draft_test}", manager_headers)
assert reloaded.status_code == 404
def test_red_lead_cannot_delete_test(api, red_lead_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", red_lead_headers)
assert resp.status_code == 403
def test_admin_cannot_delete_test(api, auth_headers, draft_test):
resp = api("delete", f"/api/v1/tests/{draft_test}", auth_headers)
assert resp.status_code == 403
def test_manager_cannot_delete_started_test(api, manager_headers, red_tech_headers, draft_test):
started = api("post", f"/api/v1/tests/{draft_test}/start-execution", red_tech_headers)
assert started.status_code == 200, started.text
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 400
def test_manager_cannot_delete_test_linked_to_a_campaign(
api, db, manager_headers, red_lead_headers, draft_test,
):
campaign = api(
"post", "/api/v1/campaigns", red_lead_headers,
json={"name": "Holds the test"},
)
assert campaign.status_code == 201, campaign.text
campaign_id = campaign.json()["id"]
add = api(
"post", f"/api/v1/campaigns/{campaign_id}/tests", red_lead_headers,
json={"test_id": draft_test},
)
assert add.status_code in (200, 201), add.text
resp = api("delete", f"/api/v1/tests/{draft_test}", manager_headers)
assert resp.status_code == 400
+5
View File
@@ -372,6 +372,11 @@ export async function resumeTest(testId: string): Promise<Test> {
return data;
}
/** Delete a standalone test that hasn't started yet. Manager only. */
export async function deleteTest(testId: string): Promise<void> {
await client.delete(`/tests/${testId}`);
}
// ── Timeline ───────────────────────────────────────────────────────
/** Get the audit-log timeline for a test. */
@@ -22,9 +22,9 @@ import type { Test, TestState, TestTemplateSummary } from "../types/models";
const stateBadge: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
+2 -2
View File
@@ -21,9 +21,9 @@ const PHASES = [
const stateColors: Record<string, { bg: string; text: string; border: string }> = {
draft: { bg: "bg-gray-800", text: "text-gray-400", border: "border-gray-600" },
red_executing: { bg: "bg-orange-900/50", text: "text-orange-400", border: "border-orange-500/50" },
red_executing: { bg: "bg-red-900/50", text: "text-red-400", border: "border-red-500/50" },
red_review: { bg: "bg-amber-900/50", text: "text-amber-400", border: "border-amber-500/50" },
blue_evaluating: { bg: "bg-indigo-900/50", text: "text-indigo-400", border: "border-indigo-500/50" },
blue_evaluating: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
blue_review: { bg: "bg-purple-900/50", text: "text-purple-400", border: "border-purple-500/50" },
in_review: { bg: "bg-blue-900/50", text: "text-blue-400", border: "border-blue-500/50" },
validated: { bg: "bg-green-900/50", text: "text-green-400", border: "border-green-500/50" },
+12 -12
View File
@@ -83,9 +83,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
{
label: "Red Execution",
value: data.red_execution_secs,
icon: <Swords className="h-4 w-4 text-orange-400" />,
color: "text-orange-400",
bg: "bg-orange-500/10 border-orange-500/20",
icon: <Swords className="h-4 w-4 text-red-400" />,
color: "text-red-400",
bg: "bg-red-500/10 border-red-500/20",
},
{
label: "Blue Queue",
@@ -97,9 +97,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
{
label: "Blue Evaluation",
value: data.blue_evaluation_secs,
icon: <Shield className="h-4 w-4 text-indigo-400" />,
color: "text-indigo-400",
bg: "bg-indigo-500/10 border-indigo-500/20",
icon: <Shield className="h-4 w-4 text-blue-400" />,
color: "text-blue-400",
bg: "bg-blue-500/10 border-blue-500/20",
},
{
label: "Total",
@@ -127,14 +127,14 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
{data.total_secs > 0 && (
<div className="mb-5">
<div className="flex h-3 w-full gap-0.5 overflow-hidden rounded-full bg-gray-800">
<BarSegment value={data.red_execution_secs} total={data.total_secs} color="bg-orange-500" label="Red Execution" />
<BarSegment value={data.red_execution_secs} total={data.total_secs} color="bg-red-500" label="Red Execution" />
<BarSegment value={data.blue_queue_secs} total={data.total_secs} color="bg-yellow-500" label="Blue Queue" />
<BarSegment value={data.blue_evaluation_secs} total={data.total_secs} color="bg-indigo-500" label="Blue Evaluation" />
<BarSegment value={data.blue_evaluation_secs} total={data.total_secs} color="bg-blue-500" label="Blue Evaluation" />
</div>
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-gray-500">
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-orange-500" />Red Execution</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-red-500" />Red Execution</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-yellow-500" />Blue Queue</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-indigo-500" />Blue Evaluation</span>
<span className="flex items-center gap-1"><span className="h-2 w-2 rounded-sm bg-blue-500" />Blue Evaluation</span>
</div>
</div>
)}
@@ -160,9 +160,9 @@ export default function CampaignTimingPanel({ campaignId }: Props) {
</div>
{/* Mini stacked bar */}
<div className="flex h-1.5 w-full gap-0.5 overflow-hidden rounded-full bg-gray-700">
<BarSegment value={b.red_execution_secs} total={b.total_secs} color="bg-orange-500" label="Red" />
<BarSegment value={b.red_execution_secs} total={b.total_secs} color="bg-red-500" label="Red" />
<BarSegment value={b.blue_queue_secs} total={b.total_secs} color="bg-yellow-500" label="Queue" />
<BarSegment value={b.blue_evaluation_secs} total={b.total_secs} color="bg-indigo-500" label="Blue" />
<BarSegment value={b.blue_evaluation_secs} total={b.total_secs} color="bg-blue-500" label="Blue" />
</div>
<div className="mt-1 flex gap-3 text-[9px] text-gray-500 tabular-nums">
{b.red_execution_secs > 0 && <span>🗡 {fmtDuration(b.red_execution_secs)}</span>}
@@ -264,8 +264,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
<div className="relative space-y-0">
{hasRedExec && (
<PhaseRow
dotClass="border-orange-500/60"
icon={<Sword className="h-3 w-3 text-orange-400 inline" />}
dotClass="border-red-500/60"
icon={<Sword className="h-3 w-3 text-red-400 inline" />}
label={red_round_number > 1 ? `Red Team Execution (Round ${red_round_number})` : "Red Team Execution"}
startTs={red_started_at}
duration={redExecSecs}
@@ -299,8 +299,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
{hasBlueEval && (
<PhaseRow
dotClass="border-indigo-500/60"
icon={<Shield className="h-3 w-3 text-indigo-400 inline" />}
dotClass="border-blue-500/60"
icon={<Shield className="h-3 w-3 text-blue-400 inline" />}
label={blue_round_number > 1 ? `Blue Evaluation (Round ${blue_round_number})` : "Blue Evaluation"}
startTs={blue_work_started_at}
duration={blueEvalSecs}
@@ -310,8 +310,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
{hasRedValidation && (
<PhaseRow
dotClass="border-orange-400/60"
icon={<CheckCircle className="h-3 w-3 text-orange-400 inline" />}
dotClass="border-red-400/60"
icon={<CheckCircle className="h-3 w-3 text-red-400 inline" />}
label="Red Lead Validation"
badge={<ValidationBadge status={red_validation_status} />}
startTs={red_validated_at}
@@ -322,8 +322,8 @@ export default function TestPhaseTimeline({ test, testId }: TestPhaseTimelinePro
{hasBlueValidation && (
<PhaseRow
dotClass="border-indigo-400/60"
icon={<CheckCircle className="h-3 w-3 text-indigo-400 inline" />}
dotClass="border-blue-400/60"
icon={<CheckCircle className="h-3 w-3 text-blue-400 inline" />}
label="Blue Lead Validation"
badge={<ValidationBadge status={blue_validation_status} />}
startTs={blue_validated_at}
+3 -3
View File
@@ -12,11 +12,11 @@ interface WorklogTimelineProps {
}
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
red_team: { bg: "bg-red-900/30", text: "text-red-400", icon: "border-red-500/40" },
blue_validation: { bg: "bg-blue-900/30", text: "text-blue-400", icon: "border-blue-500/40" },
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
execution: { bg: "bg-red-900/30", text: "text-red-400", icon: "border-red-500/40" },
};
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
@@ -24,8 +24,8 @@ interface Props {
}
const SIDE_STYLE = {
red: "border-orange-500/40 bg-orange-900/20 text-orange-400 hover:bg-orange-900/40",
blue: "border-indigo-500/40 bg-indigo-900/20 text-indigo-400 hover:bg-indigo-900/40",
red: "border-red-500/40 bg-red-900/20 text-red-400 hover:bg-red-900/40",
blue: "border-blue-500/40 bg-blue-900/20 text-blue-400 hover:bg-blue-900/40",
};
const ELIGIBLE_ROLES: Record<"operator" | "reviewer", Record<"red" | "blue", string[]>> = {
@@ -66,14 +66,14 @@ export default function LiveTimer({
const colors = isPaused
? "border-yellow-500/40 bg-yellow-900/30 text-yellow-300"
: variant === "red"
? "border-orange-500/40 bg-orange-900/30 text-orange-300"
: "border-indigo-500/40 bg-indigo-900/30 text-indigo-300";
? "border-red-500/40 bg-red-900/30 text-red-300"
: "border-blue-500/40 bg-blue-900/30 text-blue-300";
const dotColor = isPaused
? "bg-yellow-400"
: variant === "red"
? "bg-orange-400"
: "bg-indigo-400";
? "bg-red-400"
: "bg-blue-400";
return (
<div className={`flex items-center gap-2.5 rounded-lg border px-3 py-2 ${colors}`}>
@@ -53,7 +53,7 @@ export default function ResolveDisputeModal({
onClick={() => setTargetTeam("red")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
targetTeam === "red"
? "border-orange-500 bg-orange-500/10 text-orange-400"
? "border-red-500 bg-red-500/10 text-red-400"
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
}`}
>
@@ -64,7 +64,7 @@ export default function ResolveDisputeModal({
onClick={() => setTargetTeam("blue")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-3 text-sm font-medium transition-colors ${
targetTeam === "blue"
? "border-indigo-500 bg-indigo-500/10 text-indigo-400"
? "border-blue-500 bg-blue-500/10 text-blue-400"
: "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-600"
}`}
>
@@ -41,7 +41,7 @@ export default function ReviewModal({
const [systemGaps, setSystemGaps] = useState("");
const title = isRed ? "Review Red Team Submission" : "Review Blue Team Submission";
const accent = isRed ? "orange" : "indigo";
const accent = isRed ? "red" : "blue";
const evidences = isRed ? test.red_evidences || [] : test.blue_evidences || [];
const requiresNotes = decision === "reopen" && notes.trim().length === 0;
@@ -43,8 +43,8 @@ const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
];
const TAB_COLORS: Record<TabKey, string> = {
red: "border-orange-500 text-orange-400",
blue: "border-indigo-500 text-indigo-400",
red: "border-red-500 text-red-400",
blue: "border-blue-500 text-blue-400",
summary: "border-cyan-500 text-cyan-400",
timeline: "border-gray-500 text-gray-400",
};
@@ -209,7 +209,7 @@ export default function TeamTabs({
<div className="space-y-6">
{/* Locked hint for red_tech in draft state */}
{redLockedHint && (
<div className="flex items-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/5 px-4 py-3 text-sm text-orange-400">
<div className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3 text-sm text-red-400">
<span className="text-base"></span>
{redLockedHint}
</div>
@@ -407,7 +407,7 @@ export default function TeamTabs({
<div className="space-y-6">
{/* Locked hint for blue_tech before Start Evaluation */}
{blueLockedHint && (
<div className="flex items-center gap-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 px-4 py-3 text-sm text-indigo-400">
<div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-4 py-3 text-sm text-blue-400">
<span className="text-base"></span>
{blueLockedHint}
</div>
@@ -422,7 +422,7 @@ export default function TeamTabs({
value={blueDraft.detect_procedure}
onChange={(e) => onBlueFieldChange("detect_procedure", e.target.value)}
rows={6}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 font-mono"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono"
placeholder="Describe how you detected the attack..."
/>
) : (
@@ -533,7 +533,7 @@ export default function TeamTabs({
required
value={blueDraft.detection_time}
onChange={(e) => onBlueFieldChange("detection_time", 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-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
) : (
<p className="text-sm text-gray-400">
@@ -552,7 +552,7 @@ export default function TeamTabs({
required
value={blueDraft.containment_time}
onChange={(e) => onBlueFieldChange("containment_time", 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-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
) : (
<p className="text-sm text-gray-400">
@@ -574,7 +574,7 @@ export default function TeamTabs({
value={blueDraft.blue_summary}
onChange={(e) => onBlueFieldChange("blue_summary", e.target.value)}
rows={4}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Summarize the Blue Team analysis..."
/>
) : (
@@ -602,7 +602,7 @@ export default function TeamTabs({
{/* Detection Rule Checklist */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-300">
<ShieldCheck className="h-4 w-4 text-indigo-400" />
<ShieldCheck className="h-4 w-4 text-blue-400" />
Detection Rule Evaluation
</h3>
<DetectionRuleChecklist
@@ -746,8 +746,8 @@ export default function TeamTabs({
{/* Side-by-side comparison */}
<div className="grid gap-6 md:grid-cols-2">
{/* Red Side */}
<div className="rounded-lg border border-orange-500/20 bg-orange-900/10 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-orange-400">
<div className="rounded-lg border border-red-500/20 bg-red-900/10 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-400">
<Shield className="h-4 w-4" /> Red Team (Attack)
</h3>
<dl className="space-y-3">
@@ -804,8 +804,8 @@ export default function TeamTabs({
</div>
{/* Blue Side */}
<div className="rounded-lg border border-indigo-500/20 bg-indigo-900/10 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-indigo-400">
<div className="rounded-lg border border-blue-500/20 bg-blue-900/10 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-blue-400">
<ShieldCheck className="h-4 w-4" /> Blue Team (Detection)
</h3>
<dl className="space-y-3">
@@ -52,9 +52,9 @@ const STATE_INDEX: Record<TestState, number> = {
const STATE_BADGE: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
@@ -203,7 +203,7 @@ export default function TestDetailHeader({
key="start"
onClick={onStartExecution}
disabled={isTransitioning}
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors disabled:opacity-50"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Start Execution
@@ -223,7 +223,7 @@ export default function TestDetailHeader({
onClick={onSubmitRed}
disabled={isTransitioning || !hasRedEvidence}
title={!hasRedEvidence ? "Upload at least one Red Team evidence file before submitting" : undefined}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Submit to Blue Team
@@ -282,7 +282,7 @@ export default function TestDetailHeader({
key="start-blue-work"
onClick={onStartBlueWork}
disabled={isTransitioning}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors disabled:opacity-50"
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors disabled:opacity-50"
>
{isTransitioning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
Start Evaluation
@@ -320,7 +320,7 @@ export default function TestDetailHeader({
<button
key="validate-red"
onClick={() => onOpenValidateModal("red")}
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 transition-colors"
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 transition-colors"
>
<Shield className="h-4 w-4" />
Validate Red Side
@@ -338,7 +338,7 @@ export default function TestDetailHeader({
<button
key="validate-blue"
onClick={() => onOpenValidateModal("blue")}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 transition-colors"
>
<Shield className="h-4 w-4" />
Validate Blue Side
+2 -2
View File
@@ -60,9 +60,9 @@ const typeLabels: Record<string, string> = {
const testStateColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
+2 -1
View File
@@ -60,8 +60,9 @@ export default function CampaignsPage() {
start_date: "",
});
// Admin administers the site, not campaign content — no free pass here,
// same principle as the backend's require_any_role_strict on this action.
const canCreate =
user?.role === "admin" ||
user?.role === "red_lead" ||
user?.role === "blue_lead" ||
user?.role === "manager";
+4 -4
View File
@@ -49,9 +49,9 @@ import type { TestState } from "../types/models";
const testStateBadgeColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
@@ -547,8 +547,8 @@ export default function DashboardPage() {
function PipelineFunnel({ pipeline }: { pipeline: TestPipelineCounts }) {
const stages: { key: keyof TestPipelineCounts; label: string; color: string; icon: React.ReactNode }[] = [
{ key: "draft", label: "Draft", color: "bg-gray-600", icon: <Clock className="h-4 w-4 text-gray-400" /> },
{ key: "red_executing", label: "Red Executing", color: "bg-orange-500", icon: <Play className="h-4 w-4 text-orange-400" /> },
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-indigo-500", icon: <Shield className="h-4 w-4 text-indigo-400" /> },
{ key: "red_executing", label: "Red Executing", color: "bg-red-500", icon: <Play className="h-4 w-4 text-red-400" /> },
{ key: "blue_evaluating", label: "Blue Evaluating", color: "bg-blue-500", icon: <Shield className="h-4 w-4 text-blue-400" /> },
{ key: "in_review", label: "In Review", color: "bg-blue-500", icon: <Eye className="h-4 w-4 text-blue-400" /> },
{ key: "validated", label: "Validated", color: "bg-green-500", icon: <CheckCircle className="h-4 w-4 text-green-400" /> },
{ key: "rejected", label: "Rejected", color: "bg-red-500", icon: <XCircle className="h-4 w-4 text-red-400" /> },
+2 -2
View File
@@ -453,9 +453,9 @@ const statusColors: Record<string, string> = {
not_covered: "bg-red-500/10 text-red-400 border-red-500/30",
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-500/30",
draft: "bg-gray-500/10 text-gray-400 border-gray-500/30",
red_executing: "bg-orange-500/10 text-orange-400 border-orange-500/30",
red_executing: "bg-red-500/10 text-red-400 border-red-500/30",
red_review: "bg-amber-500/10 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-500/10 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-500/10 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-500/10 text-purple-400 border-purple-500/30",
in_review: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
rejected: "bg-red-500/10 text-red-400 border-red-500/30",
+2 -2
View File
@@ -41,9 +41,9 @@ const statusBadgeColors: Record<TechniqueStatus, string> = {
const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
+2 -2
View File
@@ -622,7 +622,7 @@ export default function TestDetailPage() {
saveRedMutation.mutate();
}}
disabled={saveRedMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-orange-600 px-4 py-2 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50 transition-colors"
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{saveRedMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Red Team Fields
@@ -638,7 +638,7 @@ export default function TestDetailPage() {
saveBlueMutation.mutate();
}}
disabled={saveBlueMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50 transition-colors"
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
>
{saveBlueMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Blue Team Fields
+54 -17
View File
@@ -20,8 +20,9 @@ import {
Timer,
AlertTriangle,
Lightbulb,
Trash2,
} from "lucide-react";
import { getTests, type TestListFilters } from "../api/tests";
import { getTests, deleteTest, type TestListFilters } from "../api/tests";
import {
getProcedureSuggestions,
approveProcedureSuggestion,
@@ -29,14 +30,15 @@ import {
} from "../api/procedure-suggestions";
import type { Test, TestState } from "../types/models";
import { useAuth } from "../context/AuthContext";
import { useToast } from "../components/Toast";
/* ── Badge colour map ──────────────────────────────────────────────── */
const testStateBadgeColors: Record<TestState, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
red_executing: "bg-orange-900/50 text-orange-400 border-orange-500/30",
red_executing: "bg-red-900/50 text-red-400 border-red-500/30",
red_review: "bg-amber-900/50 text-amber-400 border-amber-500/30",
blue_evaluating: "bg-indigo-900/50 text-indigo-400 border-indigo-500/30",
blue_evaluating: "bg-blue-900/50 text-blue-400 border-blue-500/30",
blue_review: "bg-purple-900/50 text-purple-400 border-purple-500/30",
in_review: "bg-blue-900/50 text-blue-400 border-blue-500/30",
validated: "bg-green-900/50 text-green-400 border-green-500/30",
@@ -503,9 +505,9 @@ export default function TestsPage() {
{ALL_STATES.map((state) => {
const icons: Record<TestState, React.ReactNode> = {
draft: <Clock className="h-4 w-4 text-gray-400" />,
red_executing: <Play className="h-4 w-4 text-orange-400" />,
red_executing: <Play className="h-4 w-4 text-red-400" />,
red_review: <Shield className="h-4 w-4 text-amber-400" />,
blue_evaluating: <Shield className="h-4 w-4 text-indigo-400" />,
blue_evaluating: <Shield className="h-4 w-4 text-blue-400" />,
blue_review: <Shield className="h-4 w-4 text-purple-400" />,
in_review: <Eye className="h-4 w-4 text-blue-400" />,
validated: <CheckCircle className="h-4 w-4 text-green-400" />,
@@ -514,9 +516,9 @@ export default function TestsPage() {
};
const colorMap: Record<TestState, string> = {
draft: "text-gray-400",
red_executing: "text-orange-400",
red_executing: "text-red-400",
red_review: "text-amber-400",
blue_evaluating: "text-indigo-400",
blue_evaluating: "text-blue-400",
blue_review: "text-purple-400",
in_review: "text-blue-400",
validated: "text-green-400",
@@ -916,6 +918,24 @@ function TestTable({
* where seeing who's working each side is the whole point. */
showAssignees?: boolean;
}) {
const { user } = useAuth();
const { showToast } = useToast();
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (testId: string) => deleteTest(testId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tests"] });
showToast("success", "Test deleted");
},
onError: () => showToast("error", "Could not delete this test — it may already be in progress or belong to a campaign"),
});
// Manager-only queue cleanup: a not-yet-started, standalone test can be
// deleted directly. Tests already underway or attached to a campaign are
// rejected server-side — the error above covers that case.
const canDelete = user?.role === "manager";
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
@@ -997,7 +1017,7 @@ function TestTable({
{/* Waiting time — how long since Red submitted to Blue */}
<td className="py-3 px-4 text-xs whitespace-nowrap">
{test.state === "blue_evaluating" ? (
<span className="font-mono text-indigo-400">
<span className="font-mono text-blue-400">
{formatElapsed(test.blue_started_at)}
</span>
) : (
@@ -1021,15 +1041,32 @@ function TestTable({
</>
)}
<td className="py-3 pl-4">
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/tests/${test.id}`);
}}
className="text-sm text-cyan-400 hover:underline"
>
View
</button>
<div className="flex items-center gap-3">
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/tests/${test.id}`);
}}
className="text-sm text-cyan-400 hover:underline"
>
View
</button>
{canDelete && test.state === "draft" && (
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm(`Delete "${test.name}"? This cannot be undone.`)) {
deleteMutation.mutate(test.id);
}
}}
disabled={deleteMutation.isPending}
title="Delete this test (only if not started and not part of a campaign)"
className="text-gray-500 hover:text-red-400 disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
+2 -2
View File
@@ -28,8 +28,8 @@ const roleBadgeColors: Record<string, string> = {
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
red_lead: "bg-red-900/50 text-red-400 border-red-500/30",
blue_lead: "bg-blue-900/50 text-blue-400 border-blue-500/30",
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};