Compare commits
30 Commits
f1e0e0acf0
...
af65681179
| Author | SHA1 | Date | |
|---|---|---|---|
| af65681179 | |||
| 164ef25393 | |||
| 83b4d2578a | |||
| aeaf1cf243 | |||
| 952b6f27d7 | |||
| b106780102 | |||
| 6b6375ddad | |||
| f19d10e19c | |||
| 858dd3e9e7 | |||
| 15ef555131 | |||
| 108743daa5 | |||
| 5bc71f677f | |||
| 959def2f49 | |||
| 6186c246a4 | |||
| 95b5ac50c6 | |||
| 4f5ffcf3f9 | |||
| 6749daa7cb | |||
| fd47db7bea | |||
| 12a5484003 | |||
| dc5f206bf5 | |||
| 6eadc7405b | |||
| 385d402a57 | |||
| 79b573fb60 | |||
| 1c91bd543d | |||
| f6eac1641c | |||
| a6b46f5f76 | |||
| 8e7f98301e | |||
| a113f3687c | |||
| 62e3bfebe8 | |||
| 74cb946317 |
@@ -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,
|
||||
)
|
||||
@@ -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``.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user