Compare commits

...

30 Commits

Author SHA1 Message Date
kitos af65681179 fix(campaigns): hide direct remove-test button on active 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
Direct test removal is now gated to draft-only at the service layer
(active campaigns must go through a modification request that a
manager approves). The trash-icon button on the tests table still
showed for active campaigns and would always fail with a 400 —
restrict it to draft, matching the backend gate.
2026-07-04 12:02:11 +02:00
kitos 164ef25393 fix(campaigns): complete ACTION_LABELS map and timeline cache invalidation
Adds the 4 backend-logged campaign actions missing from ACTION_LABELS
(delete_campaign, generate_campaign, schedule_campaign,
recurring_campaign_run), and wires campaign-timeline invalidation into
the remaining audit-logging mutations on the page (complete, schedule,
approve/reject modification request) so the timeline panel updates
live instead of only on next reload, consistent with submit/approve/
reject campaign.
2026-07-04 12:02:11 +02:00
kitos 83b4d2578a feat(campaigns): audit timeline UI and manager role in role pickers 2026-07-04 12:02:10 +02:00
kitos aeaf1cf243 fix(campaigns): reset modification-request modal state on close, not just on success
Previously action/testId/justification only reset inside the success
handler, so cancelling and reopening the modal left stale form values.
Mirrors the reset-on-close pattern already used by AddTestToCampaignModal.
2026-07-04 12:02:10 +02:00
kitos 952b6f27d7 feat(campaigns): modification request UI on campaign detail page 2026-07-04 12:02:10 +02:00
kitos b106780102 fix(campaigns): drop dead force param from activateCampaign API client
The backend endpoint no longer accepts a force query param (a draft
campaign can never have a future start_date to force past). No frontend
code currently calls this function, but keep it matching the real
backend contract in case a future admin UI wires it up.
2026-07-04 12:02:10 +02:00
kitos 6b6375ddad feat(campaigns): submit/approve/reject UI on campaign detail page 2026-07-04 12:02:10 +02:00
kitos f19d10e19c feat(campaigns): remove lead-set start dates, add pending-approval filter 2026-07-04 12:02:10 +02:00
kitos 858dd3e9e7 docs(campaigns): explain why CampaignModificationRequest.test_id can be null 2026-07-04 12:02:10 +02:00
kitos 15ef555131 feat(campaigns): API client for approval workflow 2026-07-04 12:02:10 +02:00
kitos 108743daa5 docs(campaigns): remove stale docstring reference to deleted scheduling filter 2026-07-04 12:02:10 +02:00
kitos 5bc71f677f refactor(campaigns): remove dead future-start_date scheduling code
Now that only the manager sets start_date, and only at the moment of
approval (which immediately activates the campaign), a draft campaign
can never have a start_date. This made three things permanently
unreachable: the hourly _run_scheduled_campaign_activation cron job,
the activate_campaign 409/force future-date guard, and a query filter
hiding tests from scheduled-but-inactive draft campaigns. Removes all
three rather than leaving dead code behind. Also adds the missing
manager-cannot-directly-activate router test.
2026-07-04 12:02:10 +02:00
kitos 959def2f49 feat(campaigns): timeline endpoint, admin-only manual activation, remove lead-set dates 2026-07-04 12:02:09 +02:00
kitos 6186c246a4 feat(campaigns): modification-request router endpoints 2026-07-04 12:02:09 +02:00
kitos 95b5ac50c6 test(campaigns): promote cookie-safe request helper to shared conftest fixture
Moves the local _post cookie-clearing helper into a shared 'api' fixture
in conftest.py so later router tests in this plan (Tasks 10/11) don't
have to reinvent or forget the TestClient cookie-vs-Authorization-header
gotcha. Also adds a one-line comment at both require_any_role("manager")
call sites clarifying admin passthrough is automatic.
2026-07-04 12:02:09 +02:00
kitos 4f5ffcf3f9 feat(campaigns): submit/approve/reject router endpoints 2026-07-04 12:02:09 +02:00
kitos 6749daa7cb feat(campaigns): add get_campaign_timeline service function 2026-07-04 12:02:09 +02:00
kitos fd47db7bea fix(campaigns): null-safe test_id serialization, correct stale comments
serialize_modification_request crashed toward a string 'None' instead of
JSON null for requests whose test_id was nulled by the SET NULL cascade.
Also corrects two comments that still described the debunked ordering
theory instead of the actual SET NULL fix, and fixes a test that
asserted post-cascade state without committing first (the ORM only
picks up out-of-band DB-side SET NULL on already-loaded objects after
expire_on_commit forces a re-read, matching real router behavior).
2026-07-04 12:02:09 +02:00
kitos 12a5484003 fix(campaigns): preserve modification-request audit row after test deletion
Approving a remove_test modification request deletes the underlying Test
row, which was cascading through test_id's ON DELETE CASCADE and wiping
out the request row itself before it could be read back. Changed to
ON DELETE SET NULL so the audit record (justification, reviewer,
decision) survives. Adds regression coverage plus double-approve/reject
idempotency tests.
2026-07-04 12:02:09 +02:00
kitos dc5f206bf5 feat(campaigns): gate test edits to draft, add modification request workflow 2026-07-04 12:02:09 +02:00
kitos 6eadc7405b test(campaigns): add EntityNotFoundError and reject-clears-approval coverage 2026-07-04 12:02:09 +02:00
kitos 385d402a57 feat(campaigns): submit/approve/reject service functions 2026-07-04 12:02:09 +02:00
kitos 79b573fb60 test(campaigns): add manager role fixtures 2026-07-04 12:02:08 +02:00
kitos 1c91bd543d feat(campaigns): migration for campaign_modification_requests table 2026-07-04 12:02:08 +02:00
kitos f6eac1641c feat(campaigns): migration for campaign approval fields 2026-07-04 12:02:08 +02:00
kitos a6b46f5f76 feat(campaigns): add approval fields and CampaignModificationRequest model 2026-07-04 12:02:08 +02:00
kitos 8e7f98301e refactor(campaigns): fix approve() error message, add terminal-state test 2026-07-04 12:02:08 +02:00
kitos a113f3687c feat(campaigns): add pending_approval state to campaign domain entity 2026-07-04 12:02:08 +02:00
kitos 62e3bfebe8 docs(campaigns): implementation plan for manager approval workflow 2026-07-04 12:02:08 +02:00
kitos 74cb946317 docs(campaigns): design spec for manager approval workflow 2026-07-04 12:02:08 +02:00
24 changed files with 5462 additions and 297 deletions
@@ -0,0 +1,36 @@
"""Add approved_by, approved_at, rejection_reason to campaigns.
Revision ID: b054
Revises: b053
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b054"
down_revision = "b053"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaigns",
sa.Column("approved_by", postgresql.UUID(as_uuid=True), nullable=True),
)
op.add_column("campaigns", sa.Column("approved_at", sa.DateTime(), nullable=True))
op.add_column("campaigns", sa.Column("rejection_reason", sa.Text(), nullable=True))
op.create_foreign_key(
"fk_campaigns_approved_by_users",
"campaigns", "users",
["approved_by"], ["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_campaigns_approved_by_users", "campaigns", type_="foreignkey")
op.drop_column("campaigns", "rejection_reason")
op.drop_column("campaigns", "approved_at")
op.drop_column("campaigns", "approved_by")
@@ -0,0 +1,59 @@
"""Add campaign_modification_requests table.
Revision ID: b055
Revises: b054
Create Date: 2026-07-02
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "b055"
down_revision = "b054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"campaign_modification_requests",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"campaign_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"requested_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("action", sa.String(), nullable=False),
sa.Column(
"test_id", postgresql.UUID(as_uuid=True),
sa.ForeignKey("tests.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("order_index", sa.Integer(), nullable=True),
sa.Column("phase", sa.String(), nullable=True),
sa.Column("justification", sa.Text(), nullable=False),
sa.Column("status", sa.String(), nullable=False, server_default="pending"),
sa.Column(
"reviewed_by", postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
),
sa.Column("reviewed_at", sa.DateTime(), nullable=True),
sa.Column("review_notes", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
op.create_index(
"ix_campaign_mod_requests_campaign",
"campaign_modification_requests", ["campaign_id"],
)
op.create_index(
"ix_campaign_mod_requests_status",
"campaign_modification_requests", ["status"],
)
def downgrade() -> None:
op.drop_index("ix_campaign_mod_requests_status", table_name="campaign_modification_requests")
op.drop_index("ix_campaign_mod_requests_campaign", table_name="campaign_modification_requests")
op.drop_table("campaign_modification_requests")
@@ -0,0 +1,50 @@
"""Change campaign_modification_requests.test_id to nullable with ON DELETE SET NULL.
Approving a "remove_test" modification request deletes the underlying Test
row. With the original ON DELETE CASCADE, that delete cascaded and wiped
out the modification-request row itself, destroying the audit record
(justification, reviewer, decision) the request exists to preserve.
Revision ID: b056
Revises: b055
Create Date: 2026-07-03
"""
from alembic import op
import sqlalchemy as sa
revision = "b056"
down_revision = "b055"
branch_labels = None
depends_on = None
_FK_NAME = "campaign_modification_requests_test_id_fkey"
def upgrade() -> None:
op.alter_column(
"campaign_modification_requests", "test_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=True,
)
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
op.create_foreign_key(
_FK_NAME,
"campaign_modification_requests", "tests",
["test_id"], ["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(_FK_NAME, "campaign_modification_requests", type_="foreignkey")
op.create_foreign_key(
_FK_NAME,
"campaign_modification_requests", "tests",
["test_id"], ["id"],
ondelete="CASCADE",
)
op.alter_column(
"campaign_modification_requests", "test_id",
existing_type=sa.dialects.postgresql.UUID(as_uuid=True),
nullable=False,
)
+47 -1
View File
@@ -33,6 +33,8 @@ class CampaignStatus(str, enum.Enum):
# Assign draft = "draft"
draft = "draft"
# Assign pending_approval = "pending_approval"
pending_approval = "pending_approval"
# Assign active = "active"
active = "active"
# Assign completed = "completed"
@@ -57,7 +59,8 @@ class CampaignType(str, enum.Enum):
# Assign VALID_TRANSITIONS = {
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
CampaignStatus.draft: [CampaignStatus.active],
CampaignStatus.draft: [CampaignStatus.pending_approval, CampaignStatus.active],
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
CampaignStatus.active: [CampaignStatus.completed],
CampaignStatus.completed: [CampaignStatus.archived],
CampaignStatus.archived: [],
@@ -132,6 +135,49 @@ class CampaignEntity:
# Assign self.status = CampaignStatus.active
self.status = CampaignStatus.active
def submit_for_approval(self) -> None:
"""Transition the campaign from ``draft`` to ``pending_approval``.
Returns:
None
"""
if not self.can_transition_to(CampaignStatus.pending_approval):
raise InvalidStateTransition(
self.status.value, CampaignStatus.pending_approval.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
if self.test_count == 0:
raise BusinessRuleViolation(
"Campaign must have at least one test to submit for approval"
)
self.status = CampaignStatus.pending_approval
def approve(self) -> None:
"""Transition the campaign from ``pending_approval`` to ``active``.
Returns:
None
"""
if self.status != CampaignStatus.pending_approval:
raise InvalidStateTransition(
self.status.value, CampaignStatus.active.value,
[CampaignStatus.pending_approval.value],
)
self.status = CampaignStatus.active
def reject(self) -> None:
"""Transition the campaign from ``pending_approval`` back to ``draft``.
Returns:
None
"""
if not self.can_transition_to(CampaignStatus.draft):
raise InvalidStateTransition(
self.status.value, CampaignStatus.draft.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
self.status = CampaignStatus.draft
# Define function complete
def complete(self) -> None:
"""Transition the campaign from ``active`` to ``completed``.
-99
View File
@@ -164,96 +164,6 @@ def _run_recurring_campaigns() -> None:
db.close()
def _run_scheduled_campaign_activation() -> None:
"""Auto-activate campaigns whose start_date has arrived.
Finds all campaigns in 'draft' state with a start_date <= now,
activates them, creates Jira tickets, and notifies the red_tech team.
Runs every hour so campaigns activate within ~1 hour of their scheduled time.
"""
logger.info("Scheduled campaign auto-activation check starting...")
db = SessionLocal()
try:
from datetime import datetime as _dt
from app.models.campaign import Campaign
from app.models.user import User
from app.services.campaign_crud_service import activate_campaign as _activate
from app.services.notification_service import notify_role
from app.services.audit_service import log_action
now = _dt.utcnow()
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.status == "draft",
Campaign.start_date != None, # noqa: E711
Campaign.start_date <= now,
)
.all()
)
activated = 0
for campaign in due_campaigns:
try:
_activate(db, str(campaign.id))
notify_role(
db,
role="red_tech",
type="campaign_activated",
title="Campaign auto-activated",
message=f'Campaign "{campaign.name}" has been automatically activated on its scheduled start date.',
entity_type="campaign",
entity_id=campaign.id,
)
log_action(
db,
user_id=None,
action="auto_activate_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name, "start_date": str(campaign.start_date)},
)
# Create Jira tickets non-fatally
try:
from app.services.jira_service import (
auto_create_campaign_issue,
auto_create_test_issue,
get_campaign_jira_key,
get_test_jira_key,
)
# Use first admin user as actor for Jira auth
admin_user = db.query(User).filter(User.role == "admin").first()
if admin_user:
db.refresh(campaign)
campaign_jira_key = get_campaign_jira_key(db, str(campaign.id))
if not campaign_jira_key:
campaign_jira_key = auto_create_campaign_issue(db, campaign, admin_user)
if campaign_jira_key:
for ct in campaign.campaign_tests:
if ct.test and not get_test_jira_key(db, ct.test.id):
auto_create_test_issue(
db, ct.test, admin_user,
parent_ticket_override=campaign_jira_key,
campaign_start_date=campaign.start_date,
)
except Exception:
logger.exception("Jira auto-create failed for auto-activated campaign %s", campaign.id)
db.commit()
activated += 1
logger.info("Auto-activated campaign %s (%s)", campaign.id, campaign.name)
except Exception:
logger.exception("Failed to auto-activate campaign %s", campaign.id)
db.rollback()
logger.info("Campaign auto-activation check finished — activated %d campaigns", activated)
except Exception:
logger.exception("Campaign auto-activation job failed")
finally:
db.close()
def _run_intel_scan() -> None:
"""Execute an intel scan inside its own DB session."""
# Log info: "Scheduled intel scan job starting..."
@@ -574,15 +484,6 @@ def start_scheduler() -> None:
# Keyword argument: replace_existing
replace_existing=True,
)
# Call scheduler.add_job()
scheduler.add_job(
_run_scheduled_campaign_activation,
trigger="interval",
hours=1,
id="scheduled_campaign_activation",
name="Auto-activate campaigns on start_date (hourly)",
replace_existing=True,
)
scheduler.add_job(
_run_recurring_campaigns,
# Keyword argument: trigger
+2 -2
View File
@@ -8,7 +8,7 @@ from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
from app.models.campaign import Campaign, CampaignTest
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
from app.models.compliance import ComplianceFramework, ComplianceControl, ComplianceControlMapping
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
@@ -59,7 +59,7 @@ __all__ = [
# Literal argument value
"TestTemplateDetectionRule", "TestDetectionResult",
# Literal argument value
"Campaign", "CampaignTest",
"Campaign", "CampaignTest", "CampaignModificationRequest",
# Literal argument value
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
# Literal argument value
+61
View File
@@ -75,6 +75,13 @@ class Campaign(Base):
)
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
scheduled_at = Column(DateTime, nullable=True)
approved_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
approved_at = Column(DateTime, nullable=True)
rejection_reason = Column(Text, nullable=True)
# Assign completed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Assign target_platform = Column(String, nullable=True)
@@ -229,3 +236,57 @@ class CampaignTest(Base):
Index('ix_campaign_tests_campaign', 'campaign_id'),
Index('ix_campaign_tests_test', 'test_id'),
)
class CampaignModificationRequest(Base):
"""A lead's request to add or remove a test from an already-active campaign.
The underlying ``CampaignTest`` row is only created/deleted once a
manager approves the request — see ``approve_modification_request``
in ``campaign_crud_service.py``.
"""
__tablename__ = "campaign_modification_requests"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
)
requested_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
action = Column(String, nullable=False) # add_test, remove_test
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
# underlying Test row (see remove_test_from_campaign), and this request
# row is the audit record of that decision — it must survive the test's
# deletion, not be wiped out along with it.
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="SET NULL"),
nullable=True,
)
order_index = Column(Integer, nullable=True)
phase = Column(String, nullable=True)
justification = Column(Text, nullable=False)
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
reviewed_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime, nullable=True)
review_notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
campaign = relationship("Campaign")
test = relationship("Test")
requester = relationship("User", foreign_keys=[requested_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
Index("ix_campaign_mod_requests_status", "status"),
)
+301 -34
View File
@@ -56,6 +56,10 @@ from app.services.campaign_crud_service import (
get_campaign_history as crud_get_history,
)
from app.services.campaign_crud_service import (
get_campaign_timeline as crud_get_timeline,
)
# Import from app.services.campaign_crud_service
from app.services.campaign_crud_service import (
get_campaign_progress_data as crud_get_progress,
@@ -91,6 +95,31 @@ from app.services.campaign_crud_service import (
activate_campaign as crud_activate,
)
from app.services.campaign_crud_service import (
submit_campaign_for_approval as crud_submit,
)
from app.services.campaign_crud_service import (
approve_campaign as crud_approve,
)
from app.services.campaign_crud_service import (
reject_campaign as crud_reject,
)
from app.services.campaign_crud_service import (
create_modification_request as crud_create_mod_request,
)
from app.services.campaign_crud_service import (
approve_modification_request as crud_approve_mod_request,
)
from app.services.campaign_crud_service import (
reject_modification_request as crud_reject_mod_request,
)
from app.services.campaign_crud_service import (
list_modification_requests as crud_list_mod_requests,
)
from app.services.campaign_crud_service import (
serialize_modification_request as crud_serialize_mod_request,
)
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
@@ -124,7 +153,6 @@ class CampaignCreate(BaseModel):
tags: Optional[list[str]] = Field(default_factory=list)
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
start_date: Optional[str] = None # ISO date — campaign won't activate before this
# Define class CampaignUpdate
@@ -143,7 +171,6 @@ class CampaignUpdate(BaseModel):
tags: Optional[list[str]] = None
# Assign scheduled_at = None
scheduled_at: Optional[str] = None
start_date: Optional[str] = None # ISO date — can be updated while still in draft
# Define class AddTestPayload
@@ -172,6 +199,34 @@ class SchedulePayload(BaseModel):
next_run_at: Optional[str] = None
class ApprovePayload(BaseModel):
"""Payload for a manager approving a pending campaign."""
start_date: str # ISO date/datetime — required, campaign won't activate without it
class RejectPayload(BaseModel):
"""Payload for a manager rejecting a pending campaign."""
reason: str
class ModificationRequestPayload(BaseModel):
"""Payload for a lead requesting to add/remove a test on an active campaign."""
action: str # add_test | remove_test
test_id: str
justification: str
order_index: Optional[int] = None
phase: Optional[str] = None
class RejectModificationPayload(BaseModel):
"""Payload for a manager rejecting a modification request."""
review_notes: str
# ---------------------------------------------------------------------------
# GET /campaigns — List campaigns with filters
# ---------------------------------------------------------------------------
@@ -274,7 +329,6 @@ def create_campaign(
tags=payload.tags,
# Keyword argument: scheduled_at
scheduled_at=payload.scheduled_at,
start_date=payload.start_date,
)
campaign_id = result["id"]
log_action(
@@ -385,6 +439,227 @@ def update_campaign(
return result
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/submit — Submit draft for manager approval
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/submit")
def submit_campaign(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""Submit a draft campaign into the manager's approval queue."""
with UnitOfWork(db) as uow:
campaign = crud_submit(
db, campaign_id,
submitter_id=current_user.id,
submitter_role=current_user.role,
)
log_action(
db,
user_id=current_user.id,
action="submit_campaign_for_approval",
entity_type="campaign",
entity_id=campaign.id,
details={"name": campaign.name},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/approve — Manager approves a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/approve")
def approve_campaign_endpoint(
campaign_id: str,
payload: ApprovePayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a pending campaign, fixing its start date and activating it."""
with UnitOfWork(db) as uow:
campaign = crud_approve(
db, campaign_id,
approver_id=current_user.id,
start_date=payload.start_date,
)
log_action(
db,
user_id=current_user.id,
action="approve_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"start_date": payload.start_date},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/reject — Manager rejects a pending campaign
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/reject")
def reject_campaign_endpoint(
campaign_id: str,
payload: RejectPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a pending campaign, returning it to draft with a reason."""
with UnitOfWork(db) as uow:
campaign = crud_reject(
db, campaign_id,
rejecter_id=current_user.id,
reason=payload.reason,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={"reason": payload.reason},
)
uow.commit()
db.refresh(campaign)
return serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# POST /campaigns/{id}/modification-requests — Request a test add/remove
# ---------------------------------------------------------------------------
@router.post("/{campaign_id}/modification-requests", status_code=201)
def create_modification_request_endpoint(
campaign_id: str,
payload: ModificationRequestPayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
) -> dict:
"""File a request to add/remove a test on an active campaign — needs manager approval."""
with UnitOfWork(db) as uow:
request = crud_create_mod_request(
db, campaign_id,
requester_id=current_user.id,
action=payload.action,
test_id=payload.test_id,
justification=payload.justification,
order_index=payload.order_index,
phase=payload.phase,
)
log_action(
db,
user_id=current_user.id,
action="request_campaign_modification",
entity_type="campaign",
entity_id=campaign_id,
details={
"action": payload.action,
"test_id": payload.test_id,
"justification": payload.justification,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/modification-requests — List requests for one campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/modification-requests")
def list_campaign_modification_requests_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""List modification requests filed against a specific campaign."""
return crud_list_mod_requests(db, campaign_id=campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/modification-requests/pending — Manager's global queue
# ---------------------------------------------------------------------------
@router.get("/modification-requests/pending")
def list_pending_modification_requests_endpoint(
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> list:
"""List all modification requests awaiting manager review, across all campaigns."""
return crud_list_mod_requests(db, status="pending")
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/approve — Apply the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/approve")
def approve_modification_request_endpoint(
request_id: str,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager approves a modification request — the test change is applied now."""
with UnitOfWork(db) as uow:
request = crud_approve_mod_request(db, request_id, reviewer_id=current_user.id)
log_action(
db,
user_id=current_user.id,
action="approve_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={
"request_id": request_id,
"action": request.action,
"test_id": str(request.test_id) if request.test_id else None,
},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# POST /campaigns/modification-requests/{id}/reject — Deny the requested change
# ---------------------------------------------------------------------------
@router.post("/modification-requests/{request_id}/reject")
def reject_modification_request_endpoint(
request_id: str,
payload: RejectModificationPayload,
db: Session = Depends(get_db),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("manager")),
) -> dict:
"""Manager rejects a modification request. No change is applied."""
with UnitOfWork(db) as uow:
request = crud_reject_mod_request(
db, request_id,
reviewer_id=current_user.id,
review_notes=payload.review_notes,
)
log_action(
db,
user_id=current_user.id,
action="reject_campaign_modification",
entity_type="campaign",
entity_id=str(request.campaign_id),
details={"request_id": request_id, "review_notes": payload.review_notes},
)
uow.commit()
return crud_serialize_mod_request(db, request)
# ---------------------------------------------------------------------------
# DELETE /campaigns/{id} — Delete campaign
# ---------------------------------------------------------------------------
@@ -510,36 +785,18 @@ def remove_test_from_campaign(
def activate_campaign(
# Entry: campaign_id
campaign_id: str,
force: bool = Query(False, description="Activate even if start_date is in the future"),
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
# admin passes automatically via require_any_role's built-in bypass — do not add "admin" here
current_user: User = Depends(require_any_role("admin")),
):
"""Activate a campaign, moving it from draft to active.
"""Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue.
If the campaign has a start_date in the future and force=False, returns a 409
with a warning so the frontend can show a confirmation modal. If force=True,
activates immediately regardless of start_date.
Every other path to 'active' goes through /submit -> /approve, where the
manager sets start_date. A draft campaign never has a start_date set
(only /approve sets it, in the same call that moves the campaign to
'active'), so there is no "scheduled for the future" case to guard
against here anymore.
"""
from fastapi import HTTPException
campaign_obj = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if campaign_obj and campaign_obj.start_date and not force:
now = datetime.utcnow()
if campaign_obj.start_date > now:
raise HTTPException(
status_code=409,
detail={
"code": "start_date_in_future",
"start_date": campaign_obj.start_date.strftime("%Y-%m-%d"),
"message": (
f"This campaign is scheduled to start on "
f"{campaign_obj.start_date.strftime('%d %b %Y')}. "
f"It will activate automatically on that date. "
f"Do you want to activate it now anyway?"
),
},
)
with UnitOfWork(db) as uow:
# Assign campaign = crud_activate(db, campaign_id)
campaign = crud_activate(db, campaign_id)
@@ -693,7 +950,7 @@ def get_campaign_progress_endpoint(
# ---------------------------------------------------------------------------
class GenerateFromActorPayload(BaseModel):
start_date: Optional[str] = None # ISO date YYYY-MM-DD
pass
@router.post("/from-threat-actor/{actor_id}", status_code=201)
@@ -719,14 +976,10 @@ def generate_campaign_from_actor(
Returns:
dict: Serialised representation of the newly generated campaign.
"""
start_date_parsed = (
datetime.fromisoformat(payload.start_date) if payload.start_date else None
)
campaign = generate_campaign_from_threat_actor(
db,
uuid.UUID(actor_id),
current_user,
start_date=start_date_parsed,
)
# Open context manager
@@ -856,6 +1109,20 @@ def get_campaign_history(
return crud_get_history(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timeline — Audit-log history for this campaign
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/timeline")
def get_campaign_timeline_endpoint(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list:
"""Return the chronological audit-log history for a campaign."""
return crud_get_timeline(db, campaign_id)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/timing-summary — Aggregated timing across campaign tests
# ---------------------------------------------------------------------------
+351 -26
View File
@@ -23,8 +23,11 @@ from app.domain.errors import (
PermissionViolation,
)
# Import Campaign, CampaignTest from app.models.campaign
from app.models.campaign import Campaign, CampaignTest
# Import Campaign, CampaignModificationRequest, CampaignTest from app.models.campaign
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
# Import AuditLog from app.models.audit
from app.models.audit import AuditLog
# Import Technique from app.models.technique
from app.models.technique import Technique
@@ -125,6 +128,9 @@ def serialize_campaign(db: Session, campaign: Campaign) -> dict:
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
# Literal argument value
"completed_at": campaign.completed_at.isoformat() if campaign.completed_at else None,
"approved_by": str(campaign.approved_by) if campaign.approved_by else None,
"approved_at": campaign.approved_at.isoformat() if campaign.approved_at else None,
"rejection_reason": campaign.rejection_reason,
# Literal argument value
"target_platform": campaign.target_platform,
# Literal argument value
@@ -275,7 +281,6 @@ def create_campaign(
tags: Optional[list[str]] = None,
# Entry: scheduled_at
scheduled_at: Optional[str] = None,
start_date: Optional[str] = None,
) -> dict:
"""Create a new campaign. Does not commit; caller commits."""
# Assign campaign = Campaign(
@@ -296,7 +301,6 @@ def create_campaign(
created_by=creator_id,
# Keyword argument: scheduled_at
scheduled_at=datetime.fromisoformat(scheduled_at) if scheduled_at else None,
start_date=datetime.fromisoformat(start_date) if start_date else None,
)
# Stage new record(s) for database insertion
db.add(campaign)
@@ -306,6 +310,98 @@ def create_campaign(
return serialize_campaign(db, campaign)
def submit_campaign_for_approval(
db: Session,
campaign_id: str,
*,
submitter_id: uuid.UUID,
submitter_role: str,
) -> Campaign:
"""Submit a draft campaign into the manager's approval queue.
Raises EntityNotFoundError, PermissionViolation, BusinessRuleViolation.
Does not commit; caller commits.
"""
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
if str(campaign.created_by) != str(submitter_id) and submitter_role != "admin":
raise PermissionViolation("Only the creator or admin can submit this campaign for approval")
if campaign.status != "draft":
raise BusinessRuleViolation("Only draft campaigns can be submitted for approval")
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
if test_count == 0:
raise BusinessRuleViolation("Campaign must have at least one test to submit for approval")
campaign.status = "pending_approval"
db.flush()
return campaign
def approve_campaign(
db: Session,
campaign_id: str,
*,
approver_id: uuid.UUID,
start_date: str,
) -> Campaign:
"""Approve a pending campaign: fix its start date and activate it.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
if campaign.status != "pending_approval":
raise BusinessRuleViolation("Only campaigns pending approval can be approved")
if not start_date:
raise BusinessRuleViolation("start_date is required to approve a campaign")
campaign.start_date = datetime.fromisoformat(start_date)
campaign.status = "active"
campaign.approved_by = approver_id
campaign.approved_at = datetime.utcnow()
campaign.rejection_reason = None
db.flush()
return campaign
def reject_campaign(
db: Session,
campaign_id: str,
*,
rejecter_id: uuid.UUID,
reason: str,
) -> Campaign:
"""Reject a pending campaign, returning it to draft with a mandatory reason.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
if campaign.status != "pending_approval":
raise BusinessRuleViolation("Only campaigns pending approval can be rejected")
if not reason or not reason.strip():
raise BusinessRuleViolation("A rejection reason is required")
campaign.status = "draft"
campaign.rejection_reason = reason.strip()
campaign.approved_by = None
campaign.approved_at = None
db.flush()
return campaign
# Define function get_campaign_detail
def get_campaign_detail(db: Session, campaign_id: str) -> dict:
"""Get detailed campaign info including tests and progress.
@@ -361,8 +457,6 @@ def update_campaign(
if "scheduled_at" in fields and fields["scheduled_at"]:
# Assign fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
fields["scheduled_at"] = datetime.fromisoformat(fields["scheduled_at"])
if "start_date" in fields and fields["start_date"]:
fields["start_date"] = datetime.fromisoformat(fields["start_date"])
# Iterate over fields.items()
for field, value in fields.items():
@@ -390,6 +484,8 @@ def add_test_to_campaign(
depends_on: Optional[str] = None,
# Entry: phase
phase: Optional[str] = None,
# Entry: allowed_statuses
allowed_statuses: tuple[str, ...] = ("draft",),
) -> dict:
"""Add a test to a campaign with optional ordering and dependency.
@@ -397,20 +493,22 @@ def add_test_to_campaign(
Raises BusinessRuleViolation for invalid state or circular dependency.
Does not commit; caller commits.
"""
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
# Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
# Check: not campaign
if not campaign:
# Raise EntityNotFoundError
raise EntityNotFoundError("Campaign", campaign_id)
# Check: campaign.status not in ("draft", "active")
if campaign.status not in ("draft", "active"):
# Check: campaign.status not in allowed_statuses
if campaign.status not in allowed_statuses:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Can only add tests to draft or active campaigns")
raise BusinessRuleViolation(
f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}"
)
# Assign test = db.query(Test).filter(Test.id == test_id).first()
test = db.query(Test).filter(Test.id == test_id).first()
# Assign test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
# Check: not test
if not test:
# Raise EntityNotFoundError
@@ -426,7 +524,7 @@ def add_test_to_campaign(
max_order = (
db.query(CampaignTest.order_index)
# Chain .filter() call
.filter(CampaignTest.campaign_id == campaign_id)
.filter(CampaignTest.campaign_id == uuid.UUID(campaign_id))
# Chain .order_by() call
.order_by(CampaignTest.order_index.desc())
# Chain .first() call
@@ -459,9 +557,9 @@ def add_test_to_campaign(
# Keyword argument: id
id=ct_id,
# Keyword argument: campaign_id
campaign_id=campaign_id,
campaign_id=uuid.UUID(campaign_id),
# Keyword argument: test_id
test_id=test_id,
test_id=uuid.UUID(test_id),
# Keyword argument: order_index
order_index=final_order_index,
# Keyword argument: depends_on
@@ -492,32 +590,41 @@ def add_test_to_campaign(
# Define function remove_test_from_campaign
def remove_test_from_campaign(db: Session, campaign_id: str, campaign_test_id: str) -> None:
def remove_test_from_campaign(
db: Session,
campaign_id: str,
campaign_test_id: str,
*,
# Entry: allowed_statuses
allowed_statuses: tuple[str, ...] = ("draft",),
) -> None:
"""Remove a test from a campaign.
Raises EntityNotFoundError for missing campaign or campaign test.
Raises BusinessRuleViolation for invalid state.
Does not commit; caller commits.
"""
# Assign campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
# Assign campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
# Check: not campaign
if not campaign:
# Raise EntityNotFoundError
raise EntityNotFoundError("Campaign", campaign_id)
# Check: campaign.status not in ("draft", "active")
if campaign.status not in ("draft", "active"):
# Check: campaign.status not in allowed_statuses
if campaign.status not in allowed_statuses:
# Raise BusinessRuleViolation
raise BusinessRuleViolation("Can only modify draft or active campaigns")
raise BusinessRuleViolation(
f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}"
)
# Assign ct = (
ct = (
db.query(CampaignTest)
# Chain .filter() call
.filter(
CampaignTest.id == campaign_test_id,
CampaignTest.campaign_id == campaign_id,
CampaignTest.id == uuid.UUID(campaign_test_id),
CampaignTest.campaign_id == uuid.UUID(campaign_id),
)
# Chain .first() call
.first()
@@ -568,7 +675,7 @@ def activate_campaign(db: Session, campaign_id: str) -> Campaign:
Does not commit; caller commits.
"""
# 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
if not campaign:
# Raise EntityNotFoundError
@@ -580,7 +687,7 @@ def activate_campaign(db: Session, campaign_id: str) -> Campaign:
raise BusinessRuleViolation("Only draft campaigns can be activated")
# Assign test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_...
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign_id).count()
test_count = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).count()
# Check: test_count == 0
if test_count == 0:
# Raise BusinessRuleViolation
@@ -838,3 +945,221 @@ def get_campaign_history(db: Session, campaign_id: str) -> dict:
for child in children
],
}
# ── Campaign Modification Requests ──────────────────────────────────────
def serialize_modification_request(db: Session, request: CampaignModificationRequest) -> dict:
"""Serialize a modification request, including the test's display name."""
test = request.test
return {
"id": str(request.id),
"campaign_id": str(request.campaign_id),
"campaign_name": request.campaign.name if request.campaign else None,
"requested_by": str(request.requested_by) if request.requested_by else None,
"action": request.action,
"test_id": str(request.test_id) if request.test_id else None,
"test_name": test.name if test else None,
"order_index": request.order_index,
"phase": request.phase,
"justification": request.justification,
"status": request.status,
"reviewed_by": str(request.reviewed_by) if request.reviewed_by else None,
"reviewed_at": request.reviewed_at.isoformat() if request.reviewed_at else None,
"review_notes": request.review_notes,
"created_at": request.created_at.isoformat() if request.created_at else None,
}
def create_modification_request(
db: Session,
campaign_id: str,
*,
requester_id: uuid.UUID,
action: str,
test_id: str,
justification: str,
order_index: Optional[int] = None,
phase: Optional[str] = None,
) -> CampaignModificationRequest:
"""File a request to add/remove a test on an active campaign.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
if campaign.status != "active":
raise BusinessRuleViolation("Modification requests can only be created for active campaigns")
if action not in ("add_test", "remove_test"):
raise BusinessRuleViolation("action must be 'add_test' or 'remove_test'")
if not justification or not justification.strip():
raise BusinessRuleViolation("A justification is required")
test = db.query(Test).filter(Test.id == uuid.UUID(test_id)).first()
if not test:
raise EntityNotFoundError("Test", test_id)
if action == "remove_test":
ct = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == campaign.id, CampaignTest.test_id == test.id)
.first()
)
if not ct:
raise EntityNotFoundError("CampaignTest", test_id)
request = CampaignModificationRequest(
campaign_id=campaign.id,
requested_by=requester_id,
action=action,
test_id=test.id,
order_index=order_index,
phase=phase,
justification=justification.strip(),
status="pending",
)
db.add(request)
db.flush()
return request
def approve_modification_request(
db: Session,
request_id: str,
*,
reviewer_id: uuid.UUID,
) -> CampaignModificationRequest:
"""Approve a pending modification request and apply the underlying test change.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
request = (
db.query(CampaignModificationRequest)
.filter(CampaignModificationRequest.id == uuid.UUID(request_id))
.first()
)
if not request:
raise EntityNotFoundError("CampaignModificationRequest", request_id)
if request.status != "pending":
raise BusinessRuleViolation("Only pending modification requests can be approved")
# A "remove_test" approval deletes the underlying Test row below, which
# nulls this row's test_id (test_id uses ON DELETE SET NULL specifically
# so this audit record survives that delete instead of being cascaded
# away with it — see CampaignModificationRequest.test_id).
request.status = "approved"
request.reviewed_by = reviewer_id
request.reviewed_at = datetime.utcnow()
db.flush()
if request.action == "add_test":
add_test_to_campaign(
db, str(request.campaign_id),
test_id=str(request.test_id),
order_index=request.order_index,
phase=request.phase,
allowed_statuses=("active",),
)
else:
ct = (
db.query(CampaignTest)
.filter(
CampaignTest.campaign_id == request.campaign_id,
CampaignTest.test_id == request.test_id,
)
.first()
)
if not ct:
raise EntityNotFoundError("CampaignTest", str(request.test_id))
remove_test_from_campaign(
db, str(request.campaign_id), str(ct.id), allowed_statuses=("active",),
)
db.flush()
return request
def reject_modification_request(
db: Session,
request_id: str,
*,
reviewer_id: uuid.UUID,
review_notes: str,
) -> CampaignModificationRequest:
"""Reject a pending modification request. No change is applied to the campaign.
Raises EntityNotFoundError, BusinessRuleViolation.
Does not commit; caller commits.
"""
request = (
db.query(CampaignModificationRequest)
.filter(CampaignModificationRequest.id == uuid.UUID(request_id))
.first()
)
if not request:
raise EntityNotFoundError("CampaignModificationRequest", request_id)
if request.status != "pending":
raise BusinessRuleViolation("Only pending modification requests can be rejected")
if not review_notes or not review_notes.strip():
raise BusinessRuleViolation("Review notes are required to reject a modification request")
request.status = "rejected"
request.reviewed_by = reviewer_id
request.reviewed_at = datetime.utcnow()
request.review_notes = review_notes.strip()
db.flush()
return request
def list_modification_requests(
db: Session,
*,
campaign_id: Optional[str] = None,
status: Optional[str] = None,
) -> list[dict]:
"""List modification requests, optionally filtered by campaign and/or status."""
query = db.query(CampaignModificationRequest)
if campaign_id:
query = query.filter(CampaignModificationRequest.campaign_id == uuid.UUID(campaign_id))
if status:
query = query.filter(CampaignModificationRequest.status == status)
requests = query.order_by(CampaignModificationRequest.created_at.desc()).all()
return [serialize_modification_request(db, r) for r in requests]
def get_campaign_timeline(db: Session, campaign_id: str) -> list[dict]:
"""Return the chronological audit-log history for a campaign.
Raises EntityNotFoundError if the campaign does not exist.
"""
campaign = db.query(Campaign).filter(Campaign.id == uuid.UUID(campaign_id)).first()
if not campaign:
raise EntityNotFoundError("Campaign", campaign_id)
logs = (
db.query(AuditLog)
.filter(AuditLog.entity_type == "campaign", AuditLog.entity_id == str(campaign_id))
.order_by(AuditLog.timestamp.asc())
.all()
)
return [
{
"id": str(log.id),
"action": log.action,
"user_id": str(log.user_id) if log.user_id else None,
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
"details": log.details or {},
}
for log in logs
]
-4
View File
@@ -8,7 +8,6 @@ threat actors, and progress calculation.
import logging
import uuid
from datetime import datetime
from typing import Optional
# Import Session from sqlalchemy.orm
from sqlalchemy.orm import Session
@@ -179,8 +178,6 @@ def generate_campaign_from_threat_actor(
actor_id: uuid.UUID,
# Entry: user
user: User,
*,
start_date: Optional[datetime] = None,
) -> Campaign:
"""Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -238,7 +235,6 @@ def generate_campaign_from_threat_actor(
created_by=user.id,
# Keyword argument: tags
tags=[actor.name, "auto-generated"],
start_date=start_date,
)
# Stage new record(s) for database insertion
db.add(campaign)
+2 -23
View File
@@ -22,7 +22,7 @@ from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.test_template import TestTemplate
from app.models.campaign import Campaign, CampaignTest
from app.models.campaign import CampaignTest
from app.models.audit import AuditLog
from app.utils import escape_like
@@ -47,12 +47,7 @@ def list_tests(
# Entry: limit
limit: int = 50,
) -> list[Test]:
"""Return a paginated list of tests with optional filters.
Tests that belong to a campaign still in 'draft' status AND with a
start_date in the future are always excluded — they should not appear
in the team's queue until the campaign is activated on its start date.
"""
"""Return a paginated list of tests with optional filters."""
query = db.query(Test).options(joinedload(Test.technique))
# Check: state
@@ -89,22 +84,6 @@ def list_tests(
linked = db.query(CampaignTest.test_id).distinct().subquery()
query = query.filter(~Test.id.in_(linked))
# Always hide tests from scheduled campaigns that haven't started yet.
# A "scheduled-but-not-yet-active" campaign = draft status + start_date in future.
now = datetime.utcnow()
future_draft_tests = (
db.query(CampaignTest.test_id)
.join(Campaign, Campaign.id == CampaignTest.campaign_id)
.filter(
Campaign.status == "draft",
Campaign.start_date.isnot(None),
Campaign.start_date > now,
)
.distinct()
.subquery()
)
query = query.filter(~Test.id.in_(future_draft_tests))
# Return query.order_by(Test.created_at.desc()).offset(offset).limit(limit)....
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
+55
View File
@@ -217,6 +217,23 @@ def blue_lead_user(db):
return user
@pytest.fixture(scope="function")
def manager_user(db):
"""Create a manager user for testing."""
user = User(
username="manager",
email="manager@test.com",
hashed_password=hash_password("manager123"),
role="manager",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def admin_token(client, admin_user):
"""Get an auth token for the admin user."""
@@ -295,3 +312,41 @@ def blue_lead_token(client, blue_lead_user):
def blue_lead_headers(blue_lead_token):
"""Return authorization headers for blue_lead user."""
return {"Authorization": f"Bearer {blue_lead_token}"}
@pytest.fixture(scope="function")
def manager_token(client, manager_user):
"""Get an auth token for the manager user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "manager", "password": "manager123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def manager_headers(manager_token):
"""Return authorization headers for manager user."""
return {"Authorization": f"Bearer {manager_token}"}
@pytest.fixture(scope="function")
def api(client):
"""Issue an authenticated request while avoiding stale-cookie role bleed.
``client``'s cookie jar persists across requests within a test, and
``get_current_user`` prefers the ``aegis_token`` cookie over the
``Authorization`` header. A test that uses more than one role's
``*_headers`` fixture (e.g. submit as red_lead, then approve as
manager) would otherwise have its *first* request silently
authenticate as whichever role's fixture happens to log in last,
since pytest resolves all fixtures before the test body runs. Use
this instead of ``client.post``/``client.get`` directly whenever a
test mixes more than one role.
Usage: ``api("post", url, headers, json=payload)``.
"""
def _request(method: str, url: str, headers: dict, **kwargs):
client.cookies.clear()
return getattr(client, method)(url, headers=headers, **kwargs)
return _request
+406
View File
@@ -0,0 +1,406 @@
"""Tests for the campaign manager-approval workflow."""
import uuid
import pytest
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
from app.models.enums import TestState
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError, PermissionViolation
from app.services.campaign_crud_service import (
create_campaign,
submit_campaign_for_approval,
approve_campaign,
reject_campaign,
)
@pytest.fixture
def draft_campaign_with_test(db, admin_user):
"""A draft campaign with exactly one test, created by admin_user."""
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
campaign = Campaign(name="Approval Test Campaign", type="custom", status="draft", created_by=admin_user.id)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=admin_user.id)
db.add(test)
db.flush()
ct = CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0)
db.add(ct)
db.commit()
db.refresh(campaign)
return campaign
class TestSubmitCampaignForApproval:
def test_submit_moves_draft_to_pending_approval(self, db, draft_campaign_with_test, admin_user):
campaign = submit_campaign_for_approval(
db, str(draft_campaign_with_test.id),
submitter_id=admin_user.id, submitter_role="admin",
)
assert campaign.status == "pending_approval"
def test_submit_without_tests_raises(self, db, admin_user):
campaign = Campaign(name="Empty", type="custom", status="draft", created_by=admin_user.id)
db.add(campaign)
db.commit()
with pytest.raises(BusinessRuleViolation, match="at least one test"):
submit_campaign_for_approval(
db, str(campaign.id), submitter_id=admin_user.id, submitter_role="admin",
)
def test_submit_by_non_creator_non_admin_raises(self, db, draft_campaign_with_test, red_lead_user):
with pytest.raises(PermissionViolation):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id),
submitter_id=red_lead_user.id, submitter_role="red_lead",
)
def test_submit_non_draft_raises(self, db, draft_campaign_with_test, admin_user):
draft_campaign_with_test.status = "active"
db.commit()
with pytest.raises(BusinessRuleViolation, match="Only draft campaigns"):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id),
submitter_id=admin_user.id, submitter_role="admin",
)
def test_submit_nonexistent_campaign_raises(self, db, admin_user):
with pytest.raises(EntityNotFoundError):
submit_campaign_for_approval(
db, str(uuid.uuid4()), submitter_id=admin_user.id, submitter_role="admin",
)
class TestApproveCampaign:
def test_approve_sets_start_date_and_activates(self, db, draft_campaign_with_test, admin_user):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
)
db.commit()
campaign = approve_campaign(
db, str(draft_campaign_with_test.id),
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
)
assert campaign.status == "active"
assert campaign.start_date is not None
assert campaign.approved_by == admin_user.id
assert campaign.approved_at is not None
def test_approve_without_start_date_raises(self, db, draft_campaign_with_test, admin_user):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
)
db.commit()
with pytest.raises(BusinessRuleViolation, match="start_date is required"):
approve_campaign(db, str(draft_campaign_with_test.id), approver_id=admin_user.id, start_date="")
def test_approve_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
with pytest.raises(BusinessRuleViolation, match="pending approval"):
approve_campaign(
db, str(draft_campaign_with_test.id),
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
)
def test_approve_nonexistent_campaign_raises(self, db, admin_user):
with pytest.raises(EntityNotFoundError):
approve_campaign(
db, str(uuid.uuid4()), approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
)
class TestRejectCampaign:
def test_reject_returns_to_draft_with_reason(self, db, draft_campaign_with_test, admin_user):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
)
db.commit()
campaign = reject_campaign(
db, str(draft_campaign_with_test.id),
rejecter_id=admin_user.id, reason="Missing scope details",
)
assert campaign.status == "draft"
assert campaign.rejection_reason == "Missing scope details"
def test_reject_without_reason_raises(self, db, draft_campaign_with_test, admin_user):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
)
db.commit()
with pytest.raises(BusinessRuleViolation, match="reason is required"):
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="")
def test_reject_non_pending_raises(self, db, draft_campaign_with_test, admin_user):
with pytest.raises(BusinessRuleViolation, match="pending approval"):
reject_campaign(db, str(draft_campaign_with_test.id), rejecter_id=admin_user.id, reason="No")
def test_reject_clears_prior_approval_fields(self, db, draft_campaign_with_test, admin_user):
submit_campaign_for_approval(
db, str(draft_campaign_with_test.id), submitter_id=admin_user.id, submitter_role="admin",
)
db.commit()
approve_campaign(
db, str(draft_campaign_with_test.id),
approver_id=admin_user.id, start_date="2026-08-01T00:00:00",
)
db.commit()
# Simulate the campaign being sent back into the approval queue
# (e.g. after a modification request) so reject_campaign has
# approved_by/approved_at to clear.
draft_campaign_with_test.status = "pending_approval"
db.commit()
campaign = reject_campaign(
db, str(draft_campaign_with_test.id),
rejecter_id=admin_user.id, reason="Needs rework",
)
assert campaign.status == "draft"
assert campaign.approved_by is None
assert campaign.approved_at is None
def test_reject_nonexistent_campaign_raises(self, db, admin_user):
with pytest.raises(EntityNotFoundError):
reject_campaign(db, str(uuid.uuid4()), rejecter_id=admin_user.id, reason="No")
from app.services.campaign_crud_service import (
add_test_to_campaign,
remove_test_from_campaign,
create_modification_request,
approve_modification_request,
reject_modification_request,
list_modification_requests,
serialize_modification_request,
)
@pytest.fixture
def active_campaign_with_test(db, draft_campaign_with_test, admin_user):
"""An active campaign (bypasses the approval flow directly for test setup)."""
draft_campaign_with_test.status = "active"
db.commit()
db.refresh(draft_campaign_with_test)
return draft_campaign_with_test
class TestDirectTestEditingGate:
def test_add_test_blocked_on_active_campaign(self, db, active_campaign_with_test, admin_user):
tech = Technique(mitre_id="T1078", name="Valid Accounts", tactic="persistence", platforms=["windows"])
db.add(tech)
db.flush()
new_test = Test(technique_id=tech.id, name="New test", state=TestState.draft, created_by=admin_user.id)
db.add(new_test)
db.commit()
with pytest.raises(BusinessRuleViolation, match="draft"):
add_test_to_campaign(db, str(active_campaign_with_test.id), test_id=str(new_test.id))
def test_remove_test_blocked_on_active_campaign(self, db, active_campaign_with_test):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
with pytest.raises(BusinessRuleViolation, match="draft"):
remove_test_from_campaign(db, str(active_campaign_with_test.id), str(ct.id))
class TestModificationRequests:
def test_create_add_test_request(self, db, active_campaign_with_test, admin_user):
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
db.add(tech)
db.flush()
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
db.add(new_test)
db.commit()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="add_test",
test_id=str(new_test.id), justification="Coverage gap found in review",
)
assert request.status == "pending"
# Underlying campaign is untouched until approval
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 1
def test_create_request_without_justification_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
with pytest.raises(BusinessRuleViolation, match="justification is required"):
create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="",
)
def test_create_request_on_non_active_campaign_raises(self, db, draft_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == draft_campaign_with_test.id).first()
with pytest.raises(BusinessRuleViolation, match="active campaigns"):
create_modification_request(
db, str(draft_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Not needed",
)
def test_approve_add_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
db.add(tech)
db.flush()
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=admin_user.id)
db.add(new_test)
db.commit()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="add_test",
test_id=str(new_test.id), justification="Coverage gap found in review",
)
db.commit()
approved = approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
assert approved.status == "approved"
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 2
assert any(ct.test_id == new_test.id for ct in cts)
def test_approve_remove_test_request_applies_change(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
request_id = request.id
approve_modification_request(db, str(request_id), reviewer_id=admin_user.id)
db.commit()
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 0
# The request row must survive deletion of the underlying Test — this
# locks in test_id's ondelete="SET NULL" (not CASCADE), which is what
# keeps this audit record intact after the test it referenced is gone.
# (Commit above first: ON DELETE SET NULL is applied DB-side by the
# cascade, and the ORM only picks that up on already-loaded objects
# after expire_on_commit forces a fresh read — exactly what happens
# for real in the router, which commits before serializing.)
reloaded = (
db.query(CampaignModificationRequest)
.filter(CampaignModificationRequest.id == request_id)
.first()
)
assert reloaded is not None
assert reloaded.status == "approved"
assert reloaded.reviewed_by == admin_user.id
assert reloaded.reviewed_at is not None
# Serialization must not crash and must emit a real null, not the
# string "None", for a request whose test_id has been nulled out.
assert reloaded.test_id is None
serialized = serialize_modification_request(db, reloaded)
assert serialized["test_id"] is None
assert serialized["test_name"] is None
def test_approve_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
db.commit()
with pytest.raises(BusinessRuleViolation, match="Only pending"):
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
def test_reject_request_leaves_campaign_untouched(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
rejected = reject_modification_request(
db, str(request.id), reviewer_id=admin_user.id, review_notes="Still needed for coverage",
)
assert rejected.status == "rejected"
assert rejected.review_notes == "Still needed for coverage"
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 1
def test_reject_without_notes_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
with pytest.raises(BusinessRuleViolation, match="Review notes are required"):
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="")
def test_reject_already_decided_request_raises(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
request = create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Not needed")
db.commit()
with pytest.raises(BusinessRuleViolation, match="Only pending"):
reject_modification_request(db, str(request.id), reviewer_id=admin_user.id, review_notes="Again")
def test_list_modification_requests_filters_by_status(self, db, active_campaign_with_test, admin_user):
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).first()
create_modification_request(
db, str(active_campaign_with_test.id),
requester_id=admin_user.id, action="remove_test",
test_id=str(ct.test_id), justification="Test superseded",
)
db.commit()
pending = list_modification_requests(db, status="pending")
assert len(pending) == 1
assert pending[0]["action"] == "remove_test"
from app.services.audit_service import log_action
from app.services.campaign_crud_service import get_campaign_timeline
class TestCampaignTimeline:
def test_timeline_returns_entries_in_chronological_order(self, db, draft_campaign_with_test, admin_user):
log_action(
db, user_id=admin_user.id, action="create_campaign",
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
)
log_action(
db, user_id=admin_user.id, action="submit_campaign_for_approval",
entity_type="campaign", entity_id=str(draft_campaign_with_test.id), details={},
)
db.commit()
timeline = get_campaign_timeline(db, str(draft_campaign_with_test.id))
assert len(timeline) == 2
assert timeline[0]["action"] == "create_campaign"
assert timeline[1]["action"] == "submit_campaign_for_approval"
def test_timeline_missing_campaign_raises(self, db):
import uuid as uuid_mod
with pytest.raises(EntityNotFoundError):
get_campaign_timeline(db, str(uuid_mod.uuid4()))
@@ -0,0 +1,298 @@
"""Router-level tests for the campaign manager-approval workflow."""
from app.models.campaign import Campaign, CampaignTest
from app.models.technique import Technique
from app.models.test import Test
from app.models.enums import TestState
def _make_draft_campaign(db, owner_id):
tech = Technique(mitre_id="T1059", name="Command Line", tactic="execution", platforms=["windows"])
db.add(tech)
db.flush()
campaign = Campaign(name="Router Test Campaign", type="custom", status="draft", created_by=owner_id)
db.add(campaign)
db.flush()
test = Test(technique_id=tech.id, name="T1059 test", state=TestState.draft, created_by=owner_id)
db.add(test)
db.flush()
db.add(CampaignTest(campaign_id=campaign.id, test_id=test.id, order_index=0))
db.commit()
db.refresh(campaign)
return campaign
def test_lead_can_submit_own_campaign(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "pending_approval"
def test_red_tech_cannot_submit(api, db, red_lead_user, red_tech_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_tech_headers)
assert resp.status_code == 403
def test_manager_can_approve_and_sets_start_date(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)
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
manager_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "active"
assert body["start_date"] is not None
def test_lead_cannot_approve(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
red_lead_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 403
def test_admin_can_approve_as_manager_backup(api, db, red_lead_user, red_lead_headers, auth_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/approve",
auth_headers,
json={"start_date": "2026-09-01T00:00:00"},
)
assert resp.status_code == 200
def test_manager_can_reject_with_reason(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)
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/reject",
manager_headers,
json={"reason": "Needs more detail"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "draft"
def test_manager_reject_without_reason_rejected_by_validation(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)
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/reject",
manager_headers,
json={"reason": ""},
)
assert resp.status_code == 400
def _activate_campaign_directly(db, campaign):
"""Test helper — bypass the approval flow to get straight to 'active' for setup."""
campaign.status = "active"
db.commit()
db.refresh(campaign)
return campaign
def test_lead_can_create_modification_request(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
db.add(tech)
db.flush()
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
db.add(new_test)
db.commit()
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "add_test", "test_id": str(new_test.id), "justification": "Coverage gap"},
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
def test_modification_request_without_justification_rejected(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": ""},
)
assert resp.status_code == 400
def test_manager_can_list_pending_modification_requests(api, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
)
resp = api("get", "/api/v1/campaigns/modification-requests/pending", manager_headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
def test_manager_can_approve_modification_request(api, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
create_resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
)
request_id = create_resp.json()["id"]
resp = api(
"post",
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
manager_headers,
)
assert resp.status_code == 200
assert resp.json()["status"] == "approved"
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
assert len(remaining) == 0
def test_manager_can_reject_modification_request(api, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
create_resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
)
request_id = create_resp.json()["id"]
resp = api(
"post",
f"/api/v1/campaigns/modification-requests/{request_id}/reject",
manager_headers,
json={"review_notes": "Still needed"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
remaining = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).all()
assert len(remaining) == 1
def test_lead_cannot_approve_modification_request(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
ct = db.query(CampaignTest).filter(CampaignTest.campaign_id == campaign.id).first()
create_resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/modification-requests",
red_lead_headers,
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
)
request_id = create_resp.json()["id"]
resp = api(
"post",
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
red_lead_headers,
)
assert resp.status_code == 403
def test_direct_add_test_blocked_on_active_campaign_via_router(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
_activate_campaign_directly(db, campaign)
tech = Technique(mitre_id="T1547", name="Boot Autostart", tactic="persistence", platforms=["windows"])
db.add(tech)
db.flush()
new_test = Test(technique_id=tech.id, name="Autostart test", state=TestState.draft, created_by=red_lead_user.id)
db.add(new_test)
db.commit()
resp = api(
"post",
f"/api/v1/campaigns/{campaign.id}/tests",
red_lead_headers,
json={"test_id": str(new_test.id)},
)
assert resp.status_code == 400
def test_get_campaign_timeline(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
api("post", f"/api/v1/campaigns/{campaign.id}/submit", red_lead_headers)
resp = api("get", f"/api/v1/campaigns/{campaign.id}/timeline", red_lead_headers)
assert resp.status_code == 200
actions = [e["action"] for e in resp.json()]
assert "submit_campaign_for_approval" in actions
def test_lead_cannot_directly_activate_draft_campaign(api, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", red_lead_headers)
assert resp.status_code == 403
def test_admin_can_still_directly_activate_as_override(api, db, red_lead_user, red_lead_headers, auth_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", auth_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "active"
def test_manager_cannot_directly_activate_draft_campaign(api, db, red_lead_user, manager_headers):
"""A manager approves via /approve — the direct /activate bypass is admin-only."""
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = api("post", f"/api/v1/campaigns/{campaign.id}/activate", manager_headers)
assert resp.status_code == 403
def test_create_campaign_payload_has_no_start_date_field(api, db, red_lead_headers):
resp = api(
"post",
"/api/v1/campaigns",
red_lead_headers,
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
assert resp.json()["start_date"] is None
+58
View File
@@ -173,3 +173,61 @@ def test_from_orm_handles_none_tags():
orm.tags = None
e = CampaignEntity.from_orm(orm)
assert e.tags == []
# ── 9. Submit for approval ────────────────────────────────────────────
def test_submit_for_approval_from_draft_with_tests_success():
e = _entity("draft", test_count=1)
e.submit_for_approval()
assert e.status == CampaignStatus.pending_approval
def test_submit_for_approval_from_draft_with_zero_tests_raises():
e = _entity("draft", test_count=0)
with pytest.raises(BusinessRuleViolation, match="at least one test"):
e.submit_for_approval()
assert e.status == CampaignStatus.draft
def test_submit_for_approval_from_active_raises():
e = _entity("active", test_count=1)
with pytest.raises(InvalidStateTransition):
e.submit_for_approval()
# ── 10. Approve ────────────────────────────────────────────────────────
def test_approve_from_pending_approval_success():
e = _entity("pending_approval", test_count=1)
e.approve()
assert e.status == CampaignStatus.active
def test_approve_from_draft_raises():
e = _entity("draft", test_count=1)
with pytest.raises(InvalidStateTransition):
e.approve()
def test_approve_from_completed_raises():
e = _entity("completed", test_count=1)
with pytest.raises(InvalidStateTransition):
e.approve()
# ── 11. Reject ─────────────────────────────────────────────────────────
def test_reject_from_pending_approval_returns_to_draft():
e = _entity("pending_approval", test_count=1)
e.reject()
assert e.status == CampaignStatus.draft
def test_reject_from_draft_raises():
e = _entity("draft", test_count=1)
with pytest.raises(InvalidStateTransition):
e.reject()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# Aprobación de campañas por rol Manager
**Fecha:** 2026-07-02
## Contexto
Hoy `red_lead`/`blue_lead` crean, editan, añaden/quitan tests y **activan** campañas directamente, incluida la fecha de inicio (`start_date`). No hay ningún control de aprobación intermedio, ni un registro dedicado de quién modifica qué en una campaña (solo el `AuditLog` genérico, sin endpoint de lectura para campañas).
Roles actuales: `admin`, `red_lead`, `blue_lead`, `red_tech`, `blue_tech`, `viewer`.
Estados actuales de campaña: `draft → active → completed → archived`.
## Objetivo
Los leads solo pueden "montar" campañas (crear, nombrar, añadir/quitar tests) mientras están en borrador. Un nuevo rol `manager` aprueba la campaña y es quien fija las fechas. Una vez activa, cualquier cambio de tests requiere justificación y pasa de nuevo por aprobación del manager antes de aplicarse. Todo queda registrado en un timeline consultable.
## Roles
- Nuevo rol **`manager`**, transversal (no separado por equipo red/blue).
- `admin` conserva capacidad de aprobar/rechazar, como respaldo (patrón ya usado en el resto de la app).
- `red_lead`/`blue_lead` pierden la capacidad de activar campañas directamente y de fijar `start_date`.
## Máquina de estados de campaña
```
draft ──submit(lead/admin)──> pending_approval ──approve(manager/admin, fija start_date)──> active
└──reject(manager/admin, motivo obligatorio)──> draft
active ──complete (sin cambios)──> completed ──archive (sin cambios)──> archived
```
Reglas:
- `submit` requiere que la campaña tenga ≥1 test (misma regla que hoy exige `activate`).
- `start_date` no es editable por el lead (se oculta/bloquea en creación y edición). Solo el manager la fija al aprobar; es obligatoria para poder aprobar.
- Edición de nombre/descripción/tests solo permitida en `draft`. En `pending_approval` la campaña queda congelada.
- Rechazo devuelve la campaña a `draft`, guardando el motivo; el lead puede corregir y reenviar.
## Modificación de campaña activa (añadir/quitar test)
Ninguna modificación de tests se aplica directamente sobre una campaña `active`. Se crea una entidad `CampaignModificationRequest`:
- Campos: `campaign_id`, `requested_by`, `action` (`add_test` | `remove_test`), `test_id`, `justification` (obligatoria), `status` (`pending`/`approved`/`rejected`), `reviewed_by`, `reviewed_at`, `review_notes`, `created_at`.
- Mientras la solicitud está `pending`, la campaña sigue `active` sin ningún cambio real en `CampaignTest`.
- Manager aprueba → se aplica el cambio real (añade/quita la fila de `CampaignTest`) y la solicitud pasa a `approved`.
- Manager rechaza → no se aplica ningún cambio; la solicitud pasa a `rejected` con `review_notes`.
## Timeline / auditoría
Se reutiliza el `AuditLog` genérico existente (`entity_type="campaign"`), sin tabla nueva. Nuevas acciones registradas:
`submit_campaign_for_approval`, `approve_campaign`, `reject_campaign`, `request_campaign_modification`, `approve_campaign_modification`, `reject_campaign_modification`
Nuevo endpoint de lectura `GET /campaigns/{id}/timeline` que devuelve las entradas de `AuditLog` para esa campaña ordenadas cronológicamente (mismo patrón que el timeline ya existente para tests).
## Endpoints backend
| Endpoint | Rol | Efecto |
|---|---|---|
| `POST /campaigns/{id}/submit` | lead/admin | draft → pending_approval |
| `GET /campaigns/pending-approval` | manager/admin | cola de campañas pendientes |
| `POST /campaigns/{id}/approve` | manager/admin | fija fechas, pending_approval → active |
| `POST /campaigns/{id}/reject` | manager/admin | motivo obligatorio, pending_approval → draft |
| `POST /campaigns/{id}/modification-requests` | lead/admin | crea solicitud (solo si active) |
| `GET /campaigns/modification-requests/pending` | manager/admin | cola de solicitudes pendientes |
| `POST /campaigns/modification-requests/{id}/approve` | manager/admin | aplica el cambio de test |
| `POST /campaigns/modification-requests/{id}/reject` | manager/admin | nota obligatoria, no aplica cambio |
| `GET /campaigns/{id}/timeline` | cualquiera autenticado | historial de la campaña |
Los endpoints existentes `PATCH /campaigns/{id}`, `POST/DELETE .../tests` pasan a devolver 409 fuera de `draft`.
## Modelo de datos
`backend/app/models/campaign.py`:
- Ampliar enum de `status` con `pending_approval`.
- Añadir `approved_by` (FK users, nullable), `approved_at` (datetime, nullable), `rejection_reason` (text, nullable).
- `start_date` pasa a nullable en creación (solo se rellena en `approve`).
Nuevo modelo `CampaignModificationRequest` (tabla nueva, migración Alembic):
- `id`, `campaign_id` (FK), `requested_by` (FK users), `action` (enum), `test_id` (FK tests), `justification` (text, not null), `status` (enum, default `pending`), `reviewed_by` (FK users, nullable), `reviewed_at` (datetime, nullable), `review_notes` (text, nullable), `created_at`.
## Frontend
- Rol `manager` añadido al selector de roles en gestión de usuarios.
- Botón "Enviar a aprobación" reemplaza "Activar" para el lead en campañas `draft`.
- Página/cola de aprobación para manager: lista de campañas `pending_approval` con modal de aprobar (pide `start_date` obligatoria) y modal de rechazar (pide motivo obligatorio).
- En campaña `active`: botón "Solicitar modificación" (lead) con formulario de justificación obligatoria; sección de solicitudes pendientes con aprobar/rechazar (manager, rechazo con nota obligatoria).
- Pestaña "Timeline" en el detalle de campaña, listando las entradas de auditoría en orden cronológico (mismo patrón visual que el timeline ya existente en tests).
## Testing
Tests pytest cubriendo:
- Permisos por rol en cada endpoint nuevo (lead no puede aprobar, manager no puede montar/editar tests directamente, tech no puede ni montar ni aprobar).
- `approve` falla sin `start_date`.
- `reject` falla sin motivo.
- `submit` falla si la campaña no tiene tests.
- Campaña `active` no cambia su lista de tests hasta que se aprueba la `CampaignModificationRequest`.
- Rechazo de campaña vuelve a `draft` conservando el motivo.
- `GET /campaigns/{id}/timeline` devuelve las entradas en orden cronológico correcto.
+127 -8
View File
@@ -34,6 +34,9 @@ export interface Campaign {
start_date: string | null;
scheduled_at: string | null;
completed_at: string | null;
approved_by: string | null;
approved_at: string | null;
rejection_reason: string | null;
target_platform: string | null;
tags: string[];
created_at: string | null;
@@ -65,7 +68,6 @@ export interface CampaignSummary {
export interface CampaignCreatePayload {
name: string;
description?: string;
start_date?: string; // ISO date YYYY-MM-DD — campaign won't activate before this
type?: string;
threat_actor_id?: string;
target_platform?: string;
@@ -80,6 +82,37 @@ export interface AddTestPayload {
phase?: string;
}
export type ModificationRequestAction = "add_test" | "remove_test";
export type ModificationRequestStatus = "pending" | "approved" | "rejected";
export interface CampaignModificationRequest {
id: string;
campaign_id: string;
campaign_name: string | null;
requested_by: string | null;
action: ModificationRequestAction;
/** Null once the underlying Test is deleted (e.g. an approved "remove_test"
* request) — this row is the surviving audit record, not a live link. */
test_id: string | null;
test_name: string | null;
order_index: number | null;
phase: string | null;
justification: string;
status: ModificationRequestStatus;
reviewed_by: string | null;
reviewed_at: string | null;
review_notes: string | null;
created_at: string | null;
}
export interface CampaignTimelineEntry {
id: string;
action: string;
user_id: string | null;
timestamp: string;
details: Record<string, unknown>;
}
// ── API Functions ───────────────────────────────────────────────────
/** List campaigns with optional filters. */
@@ -133,13 +166,9 @@ export async function removeTestFromCampaign(
await client.delete(`/campaigns/${campaignId}/tests/${campaignTestId}`);
}
/** Activate a campaign. */
export async function activateCampaign(
campaignId: string,
options?: { force?: boolean },
): Promise<Campaign> {
const params = options?.force ? "?force=true" : "";
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate${params}`);
/** Admin-only emergency override: activate a draft campaign directly, bypassing the approval queue. */
export async function activateCampaign(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/activate`);
return data;
}
@@ -247,3 +276,93 @@ export async function getCampaignHistory(campaignId: string): Promise<{
const { data } = await client.get(`/campaigns/${campaignId}/history`);
return data;
}
// ── Approval workflow ──────────────────────────────────────────────
/** Submit a draft campaign into the manager's approval queue. */
export async function submitCampaignForApproval(campaignId: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/submit`);
return data;
}
/** Manager approves a pending campaign, fixing its start date. */
export async function approveCampaign(campaignId: string, startDate: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/approve`, {
start_date: startDate,
});
return data;
}
/** Manager rejects a pending campaign with a mandatory reason. */
export async function rejectCampaign(campaignId: string, reason: string): Promise<Campaign> {
const { data } = await client.post<Campaign>(`/campaigns/${campaignId}/reject`, { reason });
return data;
}
// ── Modification requests ───────────────────────────────────────────
/** File a request to add/remove a test on an active campaign. */
export async function createModificationRequest(
campaignId: string,
payload: {
action: ModificationRequestAction;
test_id: string;
justification: string;
order_index?: number;
phase?: string;
},
): Promise<CampaignModificationRequest> {
const { data } = await client.post<CampaignModificationRequest>(
`/campaigns/${campaignId}/modification-requests`,
payload,
);
return data;
}
/** List modification requests filed against a specific campaign. */
export async function listCampaignModificationRequests(
campaignId: string,
): Promise<CampaignModificationRequest[]> {
const { data } = await client.get<CampaignModificationRequest[]>(
`/campaigns/${campaignId}/modification-requests`,
);
return data;
}
/** Manager's global queue of modification requests awaiting review. */
export async function listPendingModificationRequests(): Promise<CampaignModificationRequest[]> {
const { data } = await client.get<CampaignModificationRequest[]>(
"/campaigns/modification-requests/pending",
);
return data;
}
/** Manager approves a modification request — the test change is applied now. */
export async function approveModificationRequest(
requestId: string,
): Promise<CampaignModificationRequest> {
const { data } = await client.post<CampaignModificationRequest>(
`/campaigns/modification-requests/${requestId}/approve`,
);
return data;
}
/** Manager rejects a modification request with mandatory review notes. */
export async function rejectModificationRequest(
requestId: string,
reviewNotes: string,
): Promise<CampaignModificationRequest> {
const { data } = await client.post<CampaignModificationRequest>(
`/campaigns/modification-requests/${requestId}/reject`,
{ review_notes: reviewNotes },
);
return data;
}
// ── Timeline ─────────────────────────────────────────────────────────
/** Get the audit-log timeline for a campaign. */
export async function getCampaignTimeline(campaignId: string): Promise<CampaignTimelineEntry[]> {
const { data } = await client.get<CampaignTimelineEntry[]>(`/campaigns/${campaignId}/timeline`);
return data;
}
@@ -0,0 +1,71 @@
import { Clock, Loader2 } from "lucide-react";
import type { CampaignTimelineEntry } from "../api/campaigns";
interface Props {
entries: CampaignTimelineEntry[];
isLoading: boolean;
}
const ACTION_LABELS: Record<string, string> = {
create_campaign: "Campaign created",
update_campaign: "Campaign updated",
submit_campaign_for_approval: "Submitted for approval",
approve_campaign: "Approved by manager",
reject_campaign: "Rejected by manager",
activate_campaign: "Activated",
complete_campaign: "Marked completed",
request_campaign_modification: "Modification requested",
approve_campaign_modification: "Modification approved",
reject_campaign_modification: "Modification rejected",
delete_campaign: "Campaign deleted",
generate_campaign: "Generated from threat actor",
schedule_campaign: "Schedule updated",
recurring_campaign_run: "Recurring run executed",
};
export default function CampaignAuditTimeline({ entries, isLoading }: Props) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
</div>
);
}
if (entries.length === 0) {
return (
<div className="py-8 text-center">
<Clock className="mx-auto h-8 w-8 text-gray-600" />
<p className="mt-2 text-sm text-gray-400">No timeline events yet</p>
</div>
);
}
const formatDate = (d: string) =>
new Date(d).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return (
<div className="relative space-y-0">
<div className="absolute left-4 top-2 bottom-2 w-px bg-gray-700" />
{entries.map((entry, idx) => (
<div key={entry.id || idx} className="relative flex gap-4 py-3">
<div className="z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-700 bg-gray-800">
<Clock className="h-3.5 w-3.5 text-gray-400" />
</div>
<div className="flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-200">
{ACTION_LABELS[entry.action] || entry.action}
</p>
<p className="text-xs text-gray-500">{formatDate(entry.timestamp)}</p>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { X, Loader2 } from "lucide-react";
import { createModificationRequest, type CampaignTest } from "../api/campaigns";
interface Props {
campaignId: string;
tests: CampaignTest[];
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function RequestCampaignModificationModal({
campaignId,
tests,
open,
onClose,
onSuccess,
}: Props) {
const queryClient = useQueryClient();
const [action, setAction] = useState<"add_test" | "remove_test">("remove_test");
const [testId, setTestId] = useState("");
const [justification, setJustification] = useState("");
useEffect(() => {
if (!open) {
setAction("remove_test");
setTestId("");
setJustification("");
}
}, [open]);
const mutation = useMutation({
mutationFn: () =>
createModificationRequest(campaignId, {
action,
test_id: testId,
justification: justification.trim(),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
onSuccess();
onClose();
},
});
if (!open) return null;
const canSubmit = testId && justification.trim().length > 0 && !mutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Request Campaign Modification</h2>
<button onClick={onClose} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Action</label>
<select
value={action}
onChange={(e) => setAction(e.target.value as "add_test" | "remove_test")}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="remove_test">Remove a test from this campaign</option>
<option value="add_test">Add an existing test to this campaign</option>
</select>
</div>
{action === "remove_test" ? (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Test to remove</label>
<select
value={testId}
onChange={(e) => setTestId(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none"
>
<option value="">Select a test</option>
{tests.map((t) => (
<option key={t.test_id} value={t.test_id}>
{t.test_name || t.test_id}
</option>
))}
</select>
</div>
) : (
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">Test ID to add</label>
<input
value={testId}
onChange={(e) => setTestId(e.target.value)}
placeholder="Paste the existing test's ID"
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-cyan-500 focus:outline-none"
/>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Justification <span className="text-red-400">*</span>
</label>
<textarea
value={justification}
onChange={(e) => setJustification(e.target.value)}
rows={3}
placeholder="Why does this campaign need to change?"
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-cyan-500 focus:outline-none"
/>
</div>
{mutation.isError && (
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
{(mutation.error as Error)?.message || "Failed to submit request"}
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
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={() => mutation.mutate()}
disabled={!canSubmit}
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"
>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Submit Request
</button>
</div>
</div>
</div>
);
}
+242 -48
View File
@@ -19,20 +19,28 @@ import {
} from "lucide-react";
import {
getCampaign,
activateCampaign,
completeCampaign,
deleteCampaign,
removeTestFromCampaign,
scheduleCampaign,
getCampaignHistory,
submitCampaignForApproval,
approveCampaign,
rejectCampaign,
listCampaignModificationRequests,
approveModificationRequest,
rejectModificationRequest,
getCampaignTimeline,
type Campaign,
type CampaignHistoryEntry,
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
import CampaignAuditTimeline from "../components/CampaignAuditTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import CampaignTimingPanel from "../components/CampaignTimingPanel";
import AddTestToCampaignModal from "../components/AddTestToCampaignModal";
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -66,10 +74,13 @@ export default function CampaignDetailPage() {
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showAddTestModal, setShowAddTestModal] = useState(false);
const [showModRequestModal, setShowModRequestModal] = useState(false);
// 0 = hidden, 1 = first confirmation, 2 = ask about tests
const [deleteStep, setDeleteStep] = useState<0 | 1 | 2>(0);
// Start-date confirmation modal — shown when campaign has a future start_date
const [startDateWarning, setStartDateWarning] = useState<string | null>(null);
const [approveModalOpen, setApproveModalOpen] = useState(false);
const [approveStartDate, setApproveStartDate] = useState("");
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
@@ -79,6 +90,7 @@ export default function CampaignDetailPage() {
const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
const canComplete = role === "admin" || role === "red_lead";
const isManager = role === "admin" || role === "manager";
const {
data: campaign,
@@ -90,39 +102,45 @@ export default function CampaignDetailPage() {
enabled: !!campaignId,
});
const activateMutation = useMutation<Campaign, unknown, boolean>({
mutationFn: (force: boolean) => activateCampaign(campaignId!, force ? { force: true } : undefined),
const submitMutation = useMutation({
mutationFn: () => submitCampaignForApproval(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
setStartDateWarning(null);
showToast("Campaign activated", "success");
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Campaign submitted for approval", "success");
},
onError: (err: unknown) => {
// The Axios interceptor (client.ts) transforms errors into enhanced Error objects
// with .status (HTTP status), .detail (raw FastAPI detail), and .message (readable string)
type EnhancedError = Error & {
status?: number;
detail?: { code?: string; message?: string } | string;
};
const e = err as EnhancedError;
onError: (err: Error) => showToast(err.message, "error"),
});
if (e.status === 409) {
// Future start_date — show confirmation modal using the message from detail
const warningMsg =
typeof e.detail === "object" && e.detail?.message
? e.detail.message
: e.message;
setStartDateWarning(warningMsg);
} else {
showToast(e.message || "Failed to activate campaign", "error");
}
const approveMutation = useMutation({
mutationFn: () => approveCampaign(campaignId!, approveStartDate),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
setApproveModalOpen(false);
setApproveStartDate("");
showToast("Campaign approved and activated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const rejectMutation = useMutation({
mutationFn: () => rejectCampaign(campaignId!, rejectReason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
setRejectModalOpen(false);
setRejectReason("");
showToast("Campaign rejected", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const completeMutation = useMutation({
mutationFn: () => completeCampaign(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Campaign completed", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
@@ -142,6 +160,7 @@ export default function CampaignDetailPage() {
scheduleCampaign(campaignId!, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Schedule updated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
@@ -165,6 +184,40 @@ export default function CampaignDetailPage() {
enabled: !!campaignId && !!campaign?.is_recurring,
});
const { data: modRequests = [] } = useQuery({
queryKey: ["campaign-modification-requests", campaignId],
queryFn: () => listCampaignModificationRequests(campaignId!),
enabled: !!campaignId,
});
const { data: timeline = [], isLoading: isTimelineLoading } = useQuery({
queryKey: ["campaign-timeline", campaignId],
queryFn: () => getCampaignTimeline(campaignId!),
enabled: !!campaignId,
});
const approveModRequestMutation = useMutation({
mutationFn: (requestId: string) => approveModificationRequest(requestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Modification approved and applied", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const rejectModRequestMutation = useMutation({
mutationFn: ({ requestId, notes }: { requestId: string; notes: string }) =>
rejectModificationRequest(requestId, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Modification rejected", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const [schedRecurring, setSchedRecurring] = useState(false);
const [schedPattern, setSchedPattern] = useState("monthly");
const [schedNextRun, setSchedNextRun] = useState("");
@@ -262,6 +315,11 @@ export default function CampaignDetailPage() {
{campaign.description && (
<MarkdownText content={campaign.description} className="mt-1 text-sm text-gray-400" />
)}
{campaign.status === "draft" && campaign.rejection_reason && (
<div className="mt-2 rounded-lg border border-amber-500/30 bg-amber-900/20 px-3 py-2 text-xs text-amber-400">
Rejected by manager: {campaign.rejection_reason}
</div>
)}
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
{campaign.threat_actor_name && (
<button
@@ -319,18 +377,35 @@ export default function CampaignDetailPage() {
)}
{canManage && campaign.status === "draft" && (
<button
onClick={() => activateMutation.mutate(false)}
disabled={activateMutation.isPending}
onClick={() => submitMutation.mutate()}
disabled={submitMutation.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"
>
{activateMutation.isPending ? (
{submitMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Activate
Submit for Approval
</button>
)}
{isManager && campaign.status === "pending_approval" && (
<>
<button
onClick={() => setRejectModalOpen(true)}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
>
Reject
</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" && (
<button
onClick={() => completeMutation.mutate()}
@@ -583,6 +658,14 @@ export default function CampaignDetailPage() {
Add Test
</button>
)}
{canManage && campaign.status === "active" && (
<button
onClick={() => setShowModRequestModal(true)}
className="flex items-center gap-1.5 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-1.5 text-sm font-medium text-amber-400 hover:bg-amber-500/20 transition-colors"
>
Request Modification
</button>
)}
</div>
{campaign.tests.length > 0 ? (
@@ -651,7 +734,7 @@ export default function CampaignDetailPage() {
>
<Clock className="h-4 w-4" />
</button>
{canManage && (campaign.status === "draft" || campaign.status === "active") && (
{canManage && campaign.status === "draft" && (
<button
onClick={() => removeMutation.mutate(ct.id)}
disabled={removeMutation.isPending}
@@ -676,6 +759,70 @@ export default function CampaignDetailPage() {
)}
</div>
{/* Modification Requests */}
{modRequests.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Modification Requests</h2>
<div className="space-y-3">
{modRequests.map((r) => (
<div key={r.id} className="rounded-lg border border-gray-800 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-gray-200">
<span className="font-medium capitalize">{r.action.replace("_", " ")}</span>
{" — "}
{r.test_name || r.test_id || "deleted test"}
</p>
<p className="mt-1 text-xs text-gray-400">{r.justification}</p>
{r.review_notes && (
<p className="mt-1 text-xs text-amber-400">Manager notes: {r.review_notes}</p>
)}
</div>
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium ${
r.status === "pending"
? "border-amber-500/30 bg-amber-900/30 text-amber-400"
: r.status === "approved"
? "border-green-500/30 bg-green-900/30 text-green-400"
: "border-red-500/30 bg-red-900/30 text-red-400"
}`}
>
{r.status}
</span>
</div>
{isManager && r.status === "pending" && (
<div className="mt-3 flex gap-2">
<button
onClick={() => approveModRequestMutation.mutate(r.id)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-500 transition-colors"
>
Approve
</button>
<button
onClick={() => {
const notes = window.prompt("Reason for rejecting this request:");
if (notes && notes.trim()) {
rejectModRequestMutation.mutate({ requestId: r.id, notes: notes.trim() });
}
}}
className="rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-1.5 text-xs font-medium text-red-400 hover:bg-red-900/40 transition-colors"
>
Reject
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Timeline */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Timeline</h2>
<CampaignAuditTimeline entries={timeline} isLoading={isTimelineLoading} />
</div>
{/* Jira */}
<JiraLinkPanel entityType="campaign" entityId={campaignId!} readOnly label={campaign.name} />
@@ -688,31 +835,78 @@ export default function CampaignDetailPage() {
onSuccess={() => showToast("Test added to campaign", "success")}
/>
{/* Start-date confirmation modal */}
{startDateWarning && (
{/* Request Modification Modal */}
<RequestCampaignModificationModal
campaignId={campaignId!}
tests={campaign.tests}
open={showModRequestModal}
onClose={() => setShowModRequestModal(false)}
onSuccess={() => showToast("Modification request submitted", "success")}
/>
{/* Approve modal */}
{approveModalOpen && (
<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-amber-500/30 bg-gray-900 p-6 shadow-2xl">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-lg bg-amber-500/10 p-2">
<Clock className="h-5 w-5 text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-white">Campaign not started yet</h3>
</div>
<p className="mb-6 text-sm text-gray-300 leading-relaxed">{startDateWarning}</p>
<div className="flex justify-end gap-3">
<div className="mx-4 w-full max-w-md rounded-xl border border-green-500/30 bg-gray-900 p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold text-white">Approve Campaign</h3>
<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"
value={approveStartDate}
onChange={(e) => setApproveStartDate(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 [color-scheme:dark]"
/>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setStartDateWarning(null)}
onClick={() => setApproveModalOpen(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Keep scheduled
Cancel
</button>
<button
onClick={() => activateMutation.mutate(true)}
disabled={activateMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
onClick={() => approveMutation.mutate()}
disabled={!approveStartDate || approveMutation.isPending}
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 disabled:opacity-50 transition-colors"
>
{activateMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Activate now anyway
{approveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Approve &amp; Activate
</button>
</div>
</div>
</div>
)}
{/* Reject modal */}
{rejectModalOpen && (
<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-red-500/30 bg-gray-900 p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold text-white">Reject Campaign</h3>
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Reason <span className="text-red-400">*</span>
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
placeholder="Explain what needs to change before this can be approved…"
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-cyan-500 focus:outline-none"
/>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setRejectModalOpen(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={() => rejectMutation.mutate()}
disabled={!rejectReason.trim() || rejectMutation.isPending}
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"
>
{rejectMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Reject
</button>
</div>
</div>
+4 -52
View File
@@ -17,6 +17,7 @@ import { useAuth } from "../context/AuthContext";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
pending_approval: "bg-amber-900/50 text-amber-400 border-amber-500/30",
active: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
completed: "bg-green-900/50 text-green-400 border-green-500/30",
archived: "bg-gray-800/50 text-gray-500 border-gray-700/30",
@@ -49,13 +50,11 @@ export default function CampaignsPage() {
const [showCreateForm, setShowCreateForm] = useState(false);
const [showActorSelector, setShowActorSelector] = useState(false);
const [actorSearch, setActorSearch] = useState("");
const [actorStartDate, setActorStartDate] = useState("");
const [newCampaign, setNewCampaign] = useState({
name: "",
description: "",
type: "custom",
target_platform: "",
start_date: "",
});
const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
@@ -75,7 +74,7 @@ export default function CampaignsPage() {
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowCreateForm(false);
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "", start_date: "" });
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
navigate(`/campaigns/${campaign.id}`);
},
});
@@ -88,12 +87,10 @@ export default function CampaignsPage() {
});
const generateMutation = useMutation({
mutationFn: (actorId: string) =>
generateCampaignFromThreatActor(actorId, actorStartDate ? { start_date: actorStartDate } : undefined),
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId),
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowActorSelector(false);
setActorStartDate("");
navigate(`/campaigns/${campaign.id}`);
},
});
@@ -170,6 +167,7 @@ export default function CampaignsPage() {
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="pending_approval">Pending Approval</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
@@ -228,29 +226,6 @@ export default function CampaignsPage() {
</select>
</div>
</div>
{/* Start date */}
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Start date
<span className="ml-2 text-xs font-normal text-gray-500">
(optional campaign activates automatically on this date)
</span>
</label>
<input
type="date"
value={newCampaign.start_date}
min={new Date().toISOString().split("T")[0]}
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-300 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
/>
{newCampaign.start_date && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-amber-400">
<span></span>
Tests won't be queued until {new Date(newCampaign.start_date + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}. Manual activation before that date is blocked.
</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<button
@@ -292,29 +267,6 @@ export default function CampaignsPage() {
/>
</div>
{/* Start date */}
<div className="mb-4">
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Start date
<span className="ml-2 text-xs font-normal text-gray-500">
(optional — campaign activates automatically on this date)
</span>
</label>
<input
type="date"
value={actorStartDate}
min={new Date().toISOString().split("T")[0]}
onChange={(e) => setActorStartDate(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 focus:border-cyan-500 focus:outline-none [color-scheme:dark]"
/>
{actorStartDate && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-amber-400">
<span>⏰</span>
Tests won't be queued until {new Date(actorStartDate + "T00:00:00").toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}
</p>
)}
</div>
{/* Actor list */}
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800">
{isLoadingActors ? (
+1
View File
@@ -1217,6 +1217,7 @@ const AZURE_ROLES = [
{ value: "admin", label: "Aegis Admin", desc: "Full platform access including system settings" },
{ value: "red_lead", label: "Aegis Red Lead", desc: "Red team lead — manage tests, campaigns and templates" },
{ value: "blue_lead", label: "Aegis Blue Lead", desc: "Blue team lead — validate tests and manage coverage" },
{ value: "manager", label: "Aegis Manager", desc: "Approves campaigns and campaign changes" },
{ value: "red_tech", label: "Aegis Red Tech", desc: "Red team technician — execute tests" },
{ value: "blue_tech", label: "Aegis Blue Tech", desc: "Blue team technician — review detections" },
{ value: "viewer", label: "Aegis Viewer", desc: "Read-only access to dashboards and reports" },
+2
View File
@@ -20,6 +20,7 @@ const ROLES = [
{ value: "blue_tech", label: "Blue Tech" },
{ value: "red_lead", label: "Red Lead" },
{ value: "blue_lead", label: "Blue Lead" },
{ value: "manager", label: "Manager" },
{ value: "admin", label: "Admin" },
];
@@ -29,6 +30,7 @@ const roleBadgeColors: Record<string, string> = {
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",
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};