Files
Aegis/docs/superpowers/plans/2026-07-02-campaign-manager-approval.md
T

3051 lines
114 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Campaign Manager Approval Workflow Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `manager` role that approves campaigns leads build, fixes their launch dates, and reviews justified requests to add/remove tests from an already-active campaign — with every action logged to a queryable timeline.
**Architecture:** Campaign gains a `pending_approval` status between `draft` and `active`. Leads submit drafts into that queue; a manager (or admin) approves (setting `start_date`) or rejects (with a mandatory reason, returning it to `draft`). Once `active`, leads can no longer edit the test list directly — they file a `CampaignModificationRequest` (with mandatory justification) that a manager must approve before the underlying test is actually added/removed. All of it reuses the existing generic `AuditLog` (new action names) and exposes a `GET /campaigns/{id}/timeline` endpoint, mirroring the one that already exists for tests.
**Tech Stack:** FastAPI + SQLAlchemy + Alembic (backend), React 19 + TypeScript + TanStack Query (frontend), pytest + in-memory SQLite (tests).
---
## Context for the implementing engineer
- Read [backend/app/services/campaign_crud_service.py](backend/app/services/campaign_crud_service.py) fully before starting — nearly every backend task edits this file.
- Read [backend/app/routers/campaigns.py](backend/app/routers/campaigns.py) fully before starting Task 9 onward.
- `require_any_role(*roles)` (in `app/dependencies/auth.py`) already lets `admin` through unconditionally — so `require_any_role("manager")` alone is enough to satisfy "admin can also approve." You never need to write `require_any_role("manager", "admin")`.
- Domain errors (`EntityNotFoundError`, `BusinessRuleViolation`, `PermissionViolation`) are raised in the service layer and converted to HTTP 404/400/403 automatically by `app/middleware/error_handler.py` — routers never raise `HTTPException` for these cases, they just let the exception propagate.
- Service functions never call `db.commit()` — only `db.flush()`. The router wraps calls in `with UnitOfWork(db) as uow: ... uow.commit()`. Follow this exactly.
- Backend tests use the fixtures in `backend/tests/conftest.py` (`client`, `db`, `admin_user`/`admin_token`, `red_lead_user`/`red_lead_headers`, etc.) against a real (in-memory SQLite) FastAPI app — no mocking. Run pytest from the `backend/` directory.
- Frontend has no test runner configured. Frontend tasks are verified with `npm run build` (TypeScript check) inside `frontend/` and manual verification in the browser via the dev server.
- This plan deliberately leaves `app/services/campaign_scheduler_service.py` (recurring campaign auto-clone, which sets `status="active"` directly) untouched — a recurring child campaign is a re-run of an already-approved template, not a new campaign a lead is proposing, so it doesn't need to re-enter the approval queue.
---
## Task 1: Domain entity — add `pending_approval` state and transition methods
**Files:**
- Modify: `backend/app/domain/entities/campaign.py`
- Test: `backend/tests/test_campaign_entity.py`
- [ ] **Step 1: Write the failing tests**
Append to `backend/tests/test_campaign_entity.py`:
```python
# ── 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()
# ── 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()
```
- [ ] **Step 2: Run tests to verify they fail**
Run (from `backend/`): `pytest tests/test_campaign_entity.py -v -k "submit_for_approval or test_approve or test_reject"`
Expected: FAIL — `AttributeError: 'CampaignEntity' object has no attribute 'submit_for_approval'` (and `CampaignStatus.pending_approval` doesn't exist yet).
- [ ] **Step 3: Implement the state and methods**
In `backend/app/domain/entities/campaign.py`, update `CampaignStatus`:
```python
class CampaignStatus(str, enum.Enum):
"""Lifecycle states for a campaign."""
draft = "draft"
pending_approval = "pending_approval"
active = "active"
completed = "completed"
archived = "archived"
```
Update `VALID_TRANSITIONS`:
```python
VALID_TRANSITIONS: dict[CampaignStatus, list[CampaignStatus]] = {
CampaignStatus.draft: [CampaignStatus.pending_approval],
CampaignStatus.pending_approval: [CampaignStatus.active, CampaignStatus.draft],
CampaignStatus.active: [CampaignStatus.completed],
CampaignStatus.completed: [CampaignStatus.archived],
CampaignStatus.archived: [],
}
```
Replace the `activate` method and add `submit_for_approval`, `approve`, `reject` right after it:
```python
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 not self.can_transition_to(CampaignStatus.active):
raise InvalidStateTransition(
self.status.value, CampaignStatus.active.value,
[s.value for s in VALID_TRANSITIONS[self.status]],
)
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
```
Leave the existing `activate` method as-is (it's superseded by `submit_for_approval`/`approve` but removing it would break `test_activate_from_draft_with_tests_success` etc. — out of scope to touch those).
Update `ensure_modifiable` so campaigns can still be read while pending approval isn't treated as an error state for callers that only check draft/active — leave this method unchanged; it already raises for anything other than draft/active, which is correct (pending_approval campaigns are frozen).
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_campaign_entity.py -v`
Expected: PASS — all tests including the pre-existing ones (the old `activate`/`ensure_modifiable` tests must still pass unchanged).
- [ ] **Step 5: Commit**
```bash
git add backend/app/domain/entities/campaign.py backend/tests/test_campaign_entity.py
git commit -m "feat(campaigns): add pending_approval state to campaign domain entity"
```
---
## Task 2: ORM models — Campaign approval fields + CampaignModificationRequest
**Files:**
- Modify: `backend/app/models/campaign.py`
- Modify: `backend/app/models/__init__.py`
- [ ] **Step 1: Add approval fields to `Campaign`**
In `backend/app/models/campaign.py`, add these columns right after `start_date`/`scheduled_at` (after line 77):
```python
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)
```
- [ ] **Step 2: Add the `CampaignModificationRequest` model**
Append to the bottom of `backend/app/models/campaign.py`, after the `CampaignTest` class:
```python
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
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
nullable=False,
)
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"),
)
```
- [ ] **Step 3: Register the new model**
In `backend/app/models/__init__.py`, change line 11:
```python
from app.models.campaign import Campaign, CampaignTest, CampaignModificationRequest
```
And in `__all__` (line 62), change:
```python
"Campaign", "CampaignTest", "CampaignModificationRequest",
```
- [ ] **Step 4: Verify the app still imports cleanly**
Run (from `backend/`): `python -c "import app.models; print('ok')"`
Expected: prints `ok` with no traceback.
- [ ] **Step 5: Commit**
```bash
git add backend/app/models/campaign.py backend/app/models/__init__.py
git commit -m "feat(campaigns): add approval fields and CampaignModificationRequest model"
```
---
## Task 3: Migration — campaign approval columns
**Files:**
- Create: `backend/alembic/versions/b054_add_campaign_approval_fields.py`
- [ ] **Step 1: Write the migration**
```python
"""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")
```
- [ ] **Step 2: Verify migration chain is valid**
Run (from `backend/`): `alembic heads`
Expected: prints `b054 (head)` — confirming no branch conflicts.
- [ ] **Step 3: Commit**
```bash
git add backend/alembic/versions/b054_add_campaign_approval_fields.py
git commit -m "feat(campaigns): migration for campaign approval fields"
```
---
## Task 4: Migration — campaign_modification_requests table
**Files:**
- Create: `backend/alembic/versions/b055_add_campaign_modification_requests.py`
- [ ] **Step 1: Write the migration**
```python
"""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")
```
- [ ] **Step 2: Verify migration chain is valid**
Run (from `backend/`): `alembic heads`
Expected: prints `b055 (head)`.
- [ ] **Step 3: Commit**
```bash
git add backend/alembic/versions/b055_add_campaign_modification_requests.py
git commit -m "feat(campaigns): migration for campaign_modification_requests table"
```
---
## Task 5: Test fixtures — manager role
**Files:**
- Modify: `backend/tests/conftest.py`
- [ ] **Step 1: Add the `manager_user` fixture**
Insert right after the `blue_lead_user` fixture (after line 217) in `backend/tests/conftest.py`:
```python
@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
```
- [ ] **Step 2: Add the `manager_token` and `manager_headers` fixtures**
Insert right after the `blue_lead_headers` fixture (after line 297):
```python
@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}"}
```
- [ ] **Step 3: Verify the fixtures load without error**
Run (from `backend/`): `pytest tests/test_user_api_validation.py -v`
Expected: PASS (unrelated test file — this just confirms conftest.py still parses and collects cleanly after the edit).
- [ ] **Step 4: Commit**
```bash
git add backend/tests/conftest.py
git commit -m "test(campaigns): add manager role fixtures"
```
---
## Task 6: Service layer — submit / approve / reject campaign
**Files:**
- Modify: `backend/app/services/campaign_crud_service.py`
- Test: `backend/tests/test_campaign_approval.py` (new file)
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_campaign_approval.py`:
```python
"""Tests for the campaign manager-approval workflow."""
import pytest
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
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",
)
class TestApproveCampaign:
def test_approve_sets_start_date_and_activates(self, db, draft_campaign_with_test, admin_user, manager_user=None):
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",
)
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")
```
- [ ] **Step 2: Run tests to verify they fail**
Run (from `backend/`): `pytest tests/test_campaign_approval.py -v`
Expected: FAIL — `ImportError: cannot import name 'submit_campaign_for_approval' from 'app.services.campaign_crud_service'`.
- [ ] **Step 3: Implement the service functions**
In `backend/app/services/campaign_crud_service.py`, add right after `create_campaign` (after line 306, before `get_campaign_detail`):
```python
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 == 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 == 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 == 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
```
Note: `rejecter_id` is accepted for symmetry with `approve_campaign`'s `approver_id` but isn't stored (there's no `rejected_by` column — `rejection_reason` plus the audit log entry the router writes are the record of who rejected). Leave the parameter in the signature since the router will always pass it and a future column could use it.
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_campaign_approval.py -v`
Expected: PASS — all 10 tests green.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
git commit -m "feat(campaigns): submit/approve/reject service functions"
```
---
## Task 7: Service layer — gate test add/remove to draft, add modification requests
**Files:**
- Modify: `backend/app/services/campaign_crud_service.py`
- Test: `backend/tests/test_campaign_approval.py`
- [ ] **Step 1: Write the failing tests**
Append to `backend/tests/test_campaign_approval.py`:
```python
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,
)
@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()
approve_modification_request(db, str(request.id), reviewer_id=admin_user.id)
cts = db.query(CampaignTest).filter(CampaignTest.campaign_id == active_campaign_with_test.id).all()
assert len(cts) == 0
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_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"
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_campaign_approval.py -v -k Modification or DirectTestEditingGate`
Expected: FAIL — `ImportError` for the new functions, and the existing `add_test_to_campaign`/`remove_test_from_campaign` gate tests fail because those functions currently accept `active` campaigns too.
- [ ] **Step 3: Tighten the status gate on `add_test_to_campaign` and `remove_test_from_campaign`**
In `backend/app/services/campaign_crud_service.py`, change the `add_test_to_campaign` signature (around line 379) to accept an `allowed_statuses` keyword, defaulting to draft-only:
```python
def add_test_to_campaign(
db: Session,
campaign_id: str,
*,
test_id: str,
order_index: Optional[int] = None,
depends_on: Optional[str] = None,
phase: Optional[str] = None,
allowed_statuses: tuple[str, ...] = ("draft",),
) -> dict:
```
And change its status check (currently `if campaign.status not in ("draft", "active"):`) to:
```python
if campaign.status not in allowed_statuses:
raise BusinessRuleViolation(
f"Can only add tests to a campaign in one of: {', '.join(allowed_statuses)}"
)
```
Do the same for `remove_test_from_campaign` (around line 495): add `allowed_statuses: tuple[str, ...] = ("draft",)` to its signature, and change its check to:
```python
if campaign.status not in allowed_statuses:
raise BusinessRuleViolation(
f"Can only modify tests on a campaign in one of: {', '.join(allowed_statuses)}"
)
```
This is the only change to either function's body — everything else (order_index calculation, circular-dependency validation, phase inference, dependent-cleanup) stays exactly as-is.
- [ ] **Step 4: Add the modification-request functions**
Add the import for the new model at the top of `backend/app/services/campaign_crud_service.py` (change the existing campaign model import line):
```python
from app.models.campaign import Campaign, CampaignModificationRequest, CampaignTest
```
Append these functions at the end of the file:
```python
# ── 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),
"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 == 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 == 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 == request_id)
.first()
)
if not request:
raise EntityNotFoundError("CampaignModificationRequest", request_id)
if request.status != "pending":
raise BusinessRuleViolation("Only pending modification requests can be approved")
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",),
)
request.status = "approved"
request.reviewed_by = reviewer_id
request.reviewed_at = datetime.utcnow()
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 == 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 == 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]
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `pytest tests/test_campaign_approval.py -v`
Expected: PASS — all tests from Task 6 and Task 7 green.
- [ ] **Step 6: Run the full campaign test suite to check for regressions**
Run: `pytest tests/test_campaigns_and_snapshots.py -v`
Expected: PASS — the existing `test_circular_dependency_prevention` etc. tests still pass (they operate on draft campaigns, unaffected by the tightened gate).
- [ ] **Step 7: Commit**
```bash
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
git commit -m "feat(campaigns): gate test edits to draft, add modification request workflow"
```
---
## Task 8: Service layer — campaign timeline
**Files:**
- Modify: `backend/app/services/campaign_crud_service.py`
- Test: `backend/tests/test_campaign_approval.py`
- [ ] **Step 1: Write the failing test**
Append to `backend/tests/test_campaign_approval.py`:
```python
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()))
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_campaign_approval.py -v -k CampaignTimeline`
Expected: FAIL — `ImportError: cannot import name 'get_campaign_timeline'`.
- [ ] **Step 3: Implement `get_campaign_timeline`**
Add the import at the top of `backend/app/services/campaign_crud_service.py`:
```python
from app.models.audit import AuditLog
```
Append the function at the end of the file:
```python
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 == 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
]
```
- [ ] **Step 4: Run test to verify it passes**
Run: `pytest tests/test_campaign_approval.py -v`
Expected: PASS — full file green.
- [ ] **Step 5: Commit**
```bash
git add backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval.py
git commit -m "feat(campaigns): add get_campaign_timeline service function"
```
---
## Task 9: Router — submit / approve / reject endpoints
**Files:**
- Modify: `backend/app/routers/campaigns.py`
- Test: `backend/tests/test_campaign_approval_router.py` (new file)
- [ ] **Step 1: Write the failing tests**
Create `backend/tests/test_campaign_approval_router.py`:
```python
"""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(client, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "pending_approval"
def test_red_tech_cannot_submit(client, db, red_lead_user, red_tech_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_tech_headers)
assert resp.status_code == 403
def test_manager_can_approve_and_sets_start_date(client, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.post(
f"/api/v1/campaigns/{campaign.id}/approve",
json={"start_date": "2026-09-01T00:00:00"},
headers=manager_headers,
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "active"
assert body["start_date"] is not None
def test_lead_cannot_approve(client, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.post(
f"/api/v1/campaigns/{campaign.id}/approve",
json={"start_date": "2026-09-01T00:00:00"},
headers=red_lead_headers,
)
assert resp.status_code == 403
def test_admin_can_approve_as_manager_backup(client, db, red_lead_user, red_lead_headers, auth_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.post(
f"/api/v1/campaigns/{campaign.id}/approve",
json={"start_date": "2026-09-01T00:00:00"},
headers=auth_headers,
)
assert resp.status_code == 200
def test_manager_can_reject_with_reason(client, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.post(
f"/api/v1/campaigns/{campaign.id}/reject",
json={"reason": "Needs more detail"},
headers=manager_headers,
)
assert resp.status_code == 200
assert resp.json()["status"] == "draft"
def test_manager_reject_without_reason_rejected_by_validation(client, db, red_lead_user, red_lead_headers, manager_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.post(
f"/api/v1/campaigns/{campaign.id}/reject",
json={"reason": ""},
headers=manager_headers,
)
assert resp.status_code == 400
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_campaign_approval_router.py -v`
Expected: FAIL — `404 Not Found` for `/submit`, `/approve`, `/reject` (routes don't exist yet).
- [ ] **Step 3: Add the Pydantic payloads and endpoints**
In `backend/app/routers/campaigns.py`, add the import for the new service functions (extend the existing multi-import block for `campaign_crud_service` — add right after the `activate_campaign as crud_activate` import around line 92):
```python
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,
)
```
Add two new Pydantic payloads near the other payload classes (after `SchedulePayload`, around line 173):
```python
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
```
Add the three endpoints right after `update_campaign` (after line 386, before the `DELETE /campaigns/{id}` section):
```python
# ---------------------------------------------------------------------------
# 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),
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),
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)
```
- [ ] **Step 4: Add `approved_by`, `approved_at`, `rejection_reason` to the serialized campaign**
In `serialize_campaign` in `backend/app/services/campaign_crud_service.py`, add these three keys right after `"completed_at"` (around line 127):
```python
"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,
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `pytest tests/test_campaign_approval_router.py -v`
Expected: PASS — all 7 tests green.
- [ ] **Step 6: Run the full backend test suite to check for regressions**
Run (from `backend/`): `pytest tests/ -v`
Expected: PASS — no pre-existing test broken. Pay special attention to `test_campaigns_and_snapshots.py`.
- [ ] **Step 7: Commit**
```bash
git add backend/app/routers/campaigns.py backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval_router.py
git commit -m "feat(campaigns): submit/approve/reject router endpoints"
```
---
## Task 10: Router — modification request endpoints
**Files:**
- Modify: `backend/app/routers/campaigns.py`
- Test: `backend/tests/test_campaign_approval_router.py`
- [ ] **Step 1: Write the failing tests**
Append to `backend/tests/test_campaign_approval_router.py`:
```python
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(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "add_test", "test_id": str(new_test.id), "justification": "Coverage gap"},
headers=red_lead_headers,
)
assert resp.status_code == 201
assert resp.json()["status"] == "pending"
def test_modification_request_without_justification_rejected(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": ""},
headers=red_lead_headers,
)
assert resp.status_code == 400
def test_manager_can_list_pending_modification_requests(client, 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()
client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
headers=red_lead_headers,
)
resp = client.get("/api/v1/campaigns/modification-requests/pending", headers=manager_headers)
assert resp.status_code == 200
assert len(resp.json()) == 1
def test_manager_can_approve_modification_request(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
headers=red_lead_headers,
)
request_id = create_resp.json()["id"]
resp = client.post(
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
headers=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(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
headers=red_lead_headers,
)
request_id = create_resp.json()["id"]
resp = client.post(
f"/api/v1/campaigns/modification-requests/{request_id}/reject",
json={"review_notes": "Still needed"},
headers=manager_headers,
)
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(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/modification-requests",
json={"action": "remove_test", "test_id": str(ct.test_id), "justification": "Superseded"},
headers=red_lead_headers,
)
request_id = create_resp.json()["id"]
resp = client.post(
f"/api/v1/campaigns/modification-requests/{request_id}/approve",
headers=red_lead_headers,
)
assert resp.status_code == 403
def test_direct_add_test_blocked_on_active_campaign_via_router(client, 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 = client.post(
f"/api/v1/campaigns/{campaign.id}/tests",
json={"test_id": str(new_test.id)},
headers=red_lead_headers,
)
assert resp.status_code == 400
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_campaign_approval_router.py -v -k modification`
Expected: FAIL — `404 Not Found` for the new routes (`test_direct_add_test_blocked...` should already pass since Task 7 tightened the service layer, but the route-level test confirms it end-to-end).
- [ ] **Step 3: Add the payloads and endpoints**
In `backend/app/routers/campaigns.py`, add the service imports (extend the multi-import block, same place as Task 9's imports):
```python
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,
)
```
Add the payloads near `ApprovePayload`/`RejectPayload`:
```python
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
```
Add the endpoints right after the `reject_campaign_endpoint` function from Task 9:
```python
# ---------------------------------------------------------------------------
# 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),
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),
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),
},
)
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),
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)
```
Note: `GET /campaigns/modification-requests/pending` must be declared before `GET /campaigns/{campaign_id}` would otherwise be — but since that route has a different path segment count (2 segments vs. 1), there's no actual routing conflict regardless of declaration order in this file. Add these new routes in the order shown above for readability.
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_campaign_approval_router.py -v`
Expected: PASS — all tests green, including `test_direct_add_test_blocked_on_active_campaign_via_router`.
- [ ] **Step 5: Commit**
```bash
git add backend/app/routers/campaigns.py backend/tests/test_campaign_approval_router.py
git commit -m "feat(campaigns): modification-request router endpoints"
```
---
## Task 11: Router — timeline endpoint, admin-only manual activation, drop lead-set dates
**Files:**
- Modify: `backend/app/routers/campaigns.py`
- Modify: `backend/app/services/campaign_service.py`
- Test: `backend/tests/test_campaign_approval_router.py`
- [ ] **Step 1: Write the failing tests**
Append to `backend/tests/test_campaign_approval_router.py`:
```python
def test_get_campaign_timeline(client, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
client.post(f"/api/v1/campaigns/{campaign.id}/submit", headers=red_lead_headers)
resp = client.get(f"/api/v1/campaigns/{campaign.id}/timeline", headers=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(client, db, red_lead_user, red_lead_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = client.post(f"/api/v1/campaigns/{campaign.id}/activate", headers=red_lead_headers)
assert resp.status_code == 403
def test_admin_can_still_directly_activate_as_override(client, db, red_lead_user, red_lead_headers, auth_headers):
campaign = _make_draft_campaign(db, red_lead_user.id)
resp = client.post(f"/api/v1/campaigns/{campaign.id}/activate", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["status"] == "active"
def test_create_campaign_payload_has_no_start_date_field(client, db, red_lead_headers):
resp = client.post(
"/api/v1/campaigns",
json={"name": "No date campaign", "start_date": "2026-01-01"},
headers=red_lead_headers,
)
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
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_campaign_approval_router.py -v -k "timeline or activate or start_date"`
Expected: FAIL — `404` for `/timeline`, `403`-expecting tests get `200` (lead can still activate directly today), and the create-campaign test gets a non-null `start_date`.
- [ ] **Step 3: Add the timeline endpoint**
Import the new service function in `backend/app/routers/campaigns.py` (extend the multi-import block):
```python
from app.services.campaign_crud_service import (
get_campaign_timeline as crud_get_timeline,
)
```
Add the endpoint right after `get_campaign_history` (after line 856):
```python
# ---------------------------------------------------------------------------
# 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)
```
- [ ] **Step 4: Restrict direct `/activate` to admin only**
In `backend/app/routers/campaigns.py`, change the `activate_campaign` endpoint's dependency (around line 516) from:
```python
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
```
to:
```python
current_user: User = Depends(require_any_role("admin")),
```
This closes the loophole where a lead could bypass the approval queue entirely by calling the old direct-activation endpoint. `require_any_role("admin")` only ever lets `admin` through (per its own always-pass rule), so this becomes an admin-only emergency override, matching the "admin also approves" pattern used for `/approve` and `/reject`.
- [ ] **Step 5: Remove `start_date` from lead-facing create/update/generate payloads**
In `backend/app/routers/campaigns.py`:
1. Remove the `start_date` field from `CampaignCreate` (delete the line `start_date: Optional[str] = None # ISO date...` around line 127).
2. Remove the `start_date` field from `CampaignUpdate` (delete the equivalent line around line 146).
3. Remove `start_date: Optional[str] = None` from `GenerateFromActorPayload` (around line 696) — replace the class body with `pass`:
```python
class GenerateFromActorPayload(BaseModel):
pass
```
4. In the `generate_campaign_from_actor` endpoint, remove the two lines that parse and forward `payload.start_date`:
```python
campaign = generate_campaign_from_threat_actor(
db,
uuid.UUID(actor_id),
current_user,
)
```
(delete the `start_date_parsed = ...` line entirely and the `start_date=start_date_parsed` kwarg).
In `backend/app/services/campaign_service.py`, remove the `start_date` parameter from `generate_campaign_from_threat_actor`'s signature and drop `start_date=start_date` from the `Campaign(...)` construction inside it (around lines 183 and 241).
In `backend/app/services/campaign_crud_service.py`, remove the `start_date` parameter from `create_campaign`'s signature and drop `start_date=datetime.fromisoformat(start_date) if start_date else None,` from its `Campaign(...)` construction (around lines 278 and 299). Also remove the `start_date` handling block from `update_campaign` (the `if "start_date" in fields and fields["start_date"]:` block around line 364) — with `start_date` no longer in the request schema this is dead code, but removing it makes clear the field is intentionally unreachable via this function now.
- [ ] **Step 6: Run tests to verify they pass**
Run: `pytest tests/test_campaign_approval_router.py -v`
Expected: PASS — all tests in the file green.
- [ ] **Step 7: Run the full backend test suite**
Run (from `backend/`): `pytest tests/ -v`
Expected: PASS. If `test_campaigns_and_snapshots.py` or any other file passes `start_date` to `create_campaign`/`generate_campaign_from_threat_actor` directly, update that call site to drop the argument — grep first:
Run: `grep -rn "start_date=" backend/tests/ backend/app/services/campaign_scheduler_service.py`
Fix any remaining call sites the grep turns up (the scheduler's `_clone_campaign` does not set `start_date` today, so it should need no change).
- [ ] **Step 8: Commit**
```bash
git add backend/app/routers/campaigns.py backend/app/services/campaign_service.py backend/app/services/campaign_crud_service.py backend/tests/test_campaign_approval_router.py
git commit -m "feat(campaigns): timeline endpoint, admin-only manual activation, remove lead-set dates"
```
---
## Task 12: Backend — full-suite regression check
**Files:** none (verification only)
- [ ] **Step 1: Run the complete backend test suite**
Run (from `backend/`): `pytest tests/ -v`
Expected: PASS — every test green, including all new files from Tasks 111.
- [ ] **Step 2: Run Ruff lint**
Run (from `backend/`): `ruff check app/ tests/`
Expected: no errors. Fix any import-order or unused-import issues the edits introduced (e.g. if `Optional` or `datetime` end up unused anywhere after the `start_date` removal in Task 11).
- [ ] **Step 3: Commit any lint fixes**
```bash
git add -A
git commit -m "chore(campaigns): fix lint issues from approval workflow"
```
(Skip this commit if step 2 found nothing to fix.)
---
## Task 13: Frontend — API client for the approval workflow
**Files:**
- Modify: `frontend/src/api/campaigns.ts`
- [ ] **Step 1: Add new types and drop `start_date` from create/update payloads**
In `frontend/src/api/campaigns.ts`:
1. Add `approved_by`, `approved_at`, `rejection_reason` to the `Campaign` interface, right after `completed_at`:
```typescript
approved_by: string | null;
approved_at: string | null;
rejection_reason: string | null;
```
2. Remove `start_date` from `CampaignCreatePayload` (it's now ignored by the backend and only ever settable via `approveCampaign`):
```typescript
export interface CampaignCreatePayload {
name: string;
description?: string;
type?: string;
threat_actor_id?: string;
target_platform?: string;
tags?: string[];
scheduled_at?: string;
}
```
3. Add the modification-request type and campaign timeline entry type at the end of the "Types" section:
```typescript
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;
test_id: string;
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>;
}
```
- [ ] **Step 2: Add the API functions**
Append to `frontend/src/api/campaigns.ts`:
```typescript
// ── 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;
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run (from `frontend/`): `npm run build`
Expected: build succeeds. It's expected/fine that `CampaignsPage.tsx` and `CampaignDetailPage.tsx` still reference `newCampaign.start_date` — those get fixed in Tasks 1415. If `npm run build` fails only on those two files' `start_date` usage, that confirms the type change took effect correctly; proceed to Task 14.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/api/campaigns.ts
git commit -m "feat(campaigns): API client for approval workflow"
```
---
## Task 14: Frontend — CampaignsPage: drop lead-set start dates, add pending-approval filter
**Files:**
- Modify: `frontend/src/pages/CampaignsPage.tsx`
- [ ] **Step 1: Remove the "Start date" field from the Create Campaign modal**
In `frontend/src/pages/CampaignsPage.tsx`:
1. Remove `start_date: ""` from the `newCampaign` state initializer (line 58) and from the `setNewCampaign` reset call in `createMutation.onSuccess` (line 78):
```typescript
const [newCampaign, setNewCampaign] = useState({
name: "",
description: "",
type: "custom",
target_platform: "",
});
```
```typescript
setNewCampaign({ name: "", description: "", type: "custom", target_platform: "" });
```
2. Delete the entire "Start date" `<div className="mt-4">...</div>` block (lines 232253) from the Create Campaign modal.
- [ ] **Step 2: Remove the "Start date" field from the Generate-from-Threat-Actor modal**
Remove the `actorStartDate` state (line 52) and its setter usages:
```typescript
const [actorSearch, setActorSearch] = useState("");
```
(delete the `actorStartDate` line entirely)
Update `generateMutation` (lines 9099) to stop passing a start date:
```typescript
const generateMutation = useMutation({
mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId),
onSuccess: (campaign) => {
queryClient.invalidateQueries({ queryKey: ["campaigns"] });
setShowActorSelector(false);
navigate(`/campaigns/${campaign.id}`);
},
});
```
Delete the entire "Start date" `<div className="mb-4">...</div>` block from the actor selector modal (the one right after the search input, roughly lines 295316). The `setActorStartDate("")` call that used to live in `generateMutation`'s `onSuccess` is already gone since Step 2 rewrote that whole mutation block above — no other call sites reference `actorStartDate` after this change.
- [ ] **Step 3: Add a "Pending Approval" option to the status filter**
In the status `<select>` (around line 166), add the option:
```typescript
<option value="draft">Draft</option>
<option value="pending_approval">Pending Approval</option>
<option value="active">Active</option>
```
- [ ] **Step 4: Add a status color/label for `pending_approval`**
In the `statusColors` map at the top of the file (line 18), add:
```typescript
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",
};
```
- [ ] **Step 5: Update `generateCampaignFromThreatActor`'s API signature usage**
Confirm `generateCampaignFromThreatActor` in `frontend/src/api/campaigns.ts` still accepts an optional second argument (it does, from before this plan) — the call site from Step 2 simply omits it now, which is valid since the parameter is optional. No further change needed there.
- [ ] **Step 6: Verify TypeScript compiles**
Run (from `frontend/`): `npm run build`
Expected: build succeeds with no errors referencing `CampaignsPage.tsx`.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/pages/CampaignsPage.tsx
git commit -m "feat(campaigns): remove lead-set start dates, add pending-approval filter"
```
---
## Task 15: Frontend — CampaignDetailPage: submit / approve / reject UI
**Files:**
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
- [ ] **Step 1: Import the new API functions and add `manager` to the role checks**
In `frontend/src/pages/CampaignDetailPage.tsx`, update the import block (lines 2030):
```typescript
import {
getCampaign,
activateCampaign,
completeCampaign,
deleteCampaign,
removeTestFromCampaign,
scheduleCampaign,
getCampaignHistory,
submitCampaignForApproval,
approveCampaign,
rejectCampaign,
type Campaign,
type CampaignHistoryEntry,
} from "../api/campaigns";
```
Update the role checks (lines 7981):
```typescript
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";
```
- [ ] **Step 2: Add state and mutations for submit/approve/reject**
Add right after the `startDateWarning` state (line 72):
```typescript
const [approveModalOpen, setApproveModalOpen] = useState(false);
const [approveStartDate, setApproveStartDate] = useState("");
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
```
Add right after `activateMutation` (after line 120):
```typescript
const submitMutation = useMutation({
mutationFn: () => submitCampaignForApproval(campaignId!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-timeline", campaignId] });
showToast("Campaign submitted for approval", "success");
},
onError: (err: Error) => showToast(err.message, "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"),
});
```
- [ ] **Step 3: Replace the "Activate" button with "Submit for Approval" (lead) and add Approve/Reject buttons (manager)**
Replace the existing activate button block (lines 320333):
```typescript
{canManage && campaign.status === "draft" && (
<button
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"
>
{submitMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
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>
</>
)}
```
- [ ] **Step 4: Show the rejection reason banner when a campaign was rejected**
Add right after the campaign description block, inside the header (after the `{campaign.description && (...)}` block, around line 264):
```typescript
{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>
)}
```
- [ ] **Step 5: Add the Approve and Reject modals**
Add right before the "Toast notification" block (before line 723):
```typescript
{/* 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-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={() => setApproveModalOpen(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={() => 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"
>
{approveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Approve &amp; Activate
</button>
</div>
</div>
</div>
)}
{/* Reject modal */}
{rejectModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-xl border border-red-500/30 bg-gray-900 p-6 shadow-2xl">
<h3 className="mb-4 text-lg font-semibold text-white">Reject Campaign</h3>
<label className="mb-1.5 block text-sm font-medium text-gray-300">
Reason <span className="text-red-400">*</span>
</label>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
placeholder="Explain what needs to change before this can be approved…"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setRejectModalOpen(false)}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={() => rejectMutation.mutate()}
disabled={!rejectReason.trim() || rejectMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{rejectMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Reject
</button>
</div>
</div>
</div>
)}
```
- [ ] **Step 6: Verify TypeScript compiles**
Run (from `frontend/`): `npm run build`
Expected: build succeeds.
- [ ] **Step 7: Manual verification in the browser**
Start the dev server (`preview_start` with the frontend config, or `npm run dev` inside `frontend/`). As a `red_lead` user, create a draft campaign, add a test, click "Submit for Approval," confirm the status badge changes to `pending_approval` and the Activate button is gone. Log in as a `manager` (create one via Users page if needed) and confirm the Approve/Reject buttons appear, that Approve requires a date, and that Reject requires a reason and returns the campaign to `draft` with the banner visible.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/pages/CampaignDetailPage.tsx
git commit -m "feat(campaigns): submit/approve/reject UI on campaign detail page"
```
---
## Task 16: Frontend — modification request UI
**Files:**
- Create: `frontend/src/components/RequestCampaignModificationModal.tsx`
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
- [ ] **Step 1: Create the request modal component**
Create `frontend/src/components/RequestCampaignModificationModal.tsx`:
```typescript
import { 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("");
const mutation = useMutation({
mutationFn: () =>
createModificationRequest(campaignId, {
action,
test_id: testId,
justification: justification.trim(),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", campaignId] });
setTestId("");
setJustification("");
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>
);
}
```
- [ ] **Step 2: Wire the modal and the pending-requests panel into CampaignDetailPage**
In `frontend/src/pages/CampaignDetailPage.tsx`, add imports:
```typescript
import {
listCampaignModificationRequests,
approveModificationRequest,
rejectModificationRequest,
} from "../api/campaigns";
import RequestCampaignModificationModal from "../components/RequestCampaignModificationModal";
```
Add state (near `showAddTestModal`):
```typescript
const [showModRequestModal, setShowModRequestModal] = useState(false);
```
Add the query and mutations (near `historyData`'s query):
```typescript
const { data: modRequests = [] } = useQuery({
queryKey: ["campaign-modification-requests", campaignId],
queryFn: () => listCampaignModificationRequests(campaignId!),
enabled: !!campaignId,
});
const approveModRequestMutation = useMutation({
mutationFn: (requestId: string) => approveModificationRequest(requestId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
queryClient.invalidateQueries({ queryKey: ["campaign-modification-requests", 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] });
showToast("Modification rejected", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
```
Add a "Request Modification" button next to the Tests table header, right after the existing `{canManage && campaign.status === "draft" && (...)}` Add Test button (around line 585):
```typescript
{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>
)}
```
Add a pending-modification-requests panel right after the Tests Table section closes (after line 677, before the Jira panel at line 680):
```typescript
{/* 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}
</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>
)}
{/* Request Modification Modal */}
<RequestCampaignModificationModal
campaignId={campaignId!}
tests={campaign.tests}
open={showModRequestModal}
onClose={() => setShowModRequestModal(false)}
onSuccess={() => showToast("Modification request submitted", "success")}
/>
```
- [ ] **Step 3: Verify TypeScript compiles**
Run (from `frontend/`): `npm run build`
Expected: build succeeds.
- [ ] **Step 4: Manual verification in the browser**
As a `red_lead` on an `active` campaign, click "Request Modification," pick "Remove a test," fill justification, submit. Confirm the test is still in the Tests table (unchanged) and a new "pending" entry appears under Modification Requests. Log in as `manager`, click Approve on that entry, confirm the test disappears from the Tests table and the request shows "approved."
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/RequestCampaignModificationModal.tsx frontend/src/pages/CampaignDetailPage.tsx
git commit -m "feat(campaigns): modification request UI on campaign detail page"
```
---
## Task 17: Frontend — timeline tab and role list updates
**Files:**
- Create: `frontend/src/components/CampaignAuditTimeline.tsx`
- Modify: `frontend/src/pages/CampaignDetailPage.tsx`
- Modify: `frontend/src/pages/UsersPage.tsx`
- Modify: `frontend/src/pages/SettingsPage.tsx`
- [ ] **Step 1: Create the audit timeline component**
Create `frontend/src/components/CampaignAuditTimeline.tsx`:
```typescript
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",
};
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>
);
}
```
- [ ] **Step 2: Wire the timeline into CampaignDetailPage**
Add imports:
```typescript
import { getCampaignTimeline } from "../api/campaigns";
import CampaignAuditTimeline from "../components/CampaignAuditTimeline";
```
Add the query (near `historyData`):
```typescript
const { data: timeline = [], isLoading: isTimelineLoading } = useQuery({
queryKey: ["campaign-timeline", campaignId],
queryFn: () => getCampaignTimeline(campaignId!),
enabled: !!campaignId,
});
```
Add a Timeline section right after the Modification Requests panel added in Task 16 (or right before the Jira panel if Task 16 wasn't applicable):
```typescript
{/* 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>
```
- [ ] **Step 3: Add `manager` to the Users page role list**
In `frontend/src/pages/UsersPage.tsx`, update `ROLES` (line 17):
```typescript
const ROLES = [
{ value: "viewer", label: "Viewer" },
{ value: "red_tech", label: "Red Tech" },
{ 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" },
];
```
Update `roleBadgeColors` (line 26):
```typescript
const roleBadgeColors: Record<string, string> = {
admin: "bg-purple-900/50 text-purple-400 border-purple-500/30",
red_tech: "bg-red-900/50 text-red-400 border-red-500/30",
blue_tech: "bg-blue-900/50 text-blue-400 border-blue-500/30",
red_lead: "bg-orange-900/50 text-orange-400 border-orange-500/30",
blue_lead: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
manager: "bg-amber-900/50 text-amber-400 border-amber-500/30",
viewer: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
```
- [ ] **Step 4: Add `manager` to the SSO default-role dropdown**
In `frontend/src/pages/SettingsPage.tsx`, find the role options array around line 1217 and add manager between blue_lead and red_tech (or any consistent position):
```typescript
{ 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" },
```
- [ ] **Step 5: Verify TypeScript compiles**
Run (from `frontend/`): `npm run build`
Expected: build succeeds with zero errors.
- [ ] **Step 6: Manual verification in the browser**
As `admin`, go to Users, create a user with role "Manager," confirm the badge renders correctly. Open a campaign with some audit history (submit/approve/reject a test campaign) and confirm the Timeline section lists the actions in order with readable labels.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/CampaignAuditTimeline.tsx frontend/src/pages/CampaignDetailPage.tsx frontend/src/pages/UsersPage.tsx frontend/src/pages/SettingsPage.tsx
git commit -m "feat(campaigns): audit timeline UI and manager role in role pickers"
```
---
## Task 18: Final end-to-end verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full backend suite one more time**
Run (from `backend/`): `pytest tests/ -v`
Expected: 100% pass.
- [ ] **Step 2: Run the frontend build one more time**
Run (from `frontend/`): `npm run build`
Expected: build succeeds.
- [ ] **Step 3: Full manual walkthrough in the browser**
Using the preview tools (or `npm run dev`):
1. As `red_lead`: create a campaign, add a test, submit for approval. Confirm you cannot see an Activate button and cannot edit start date anywhere.
2. As `manager`: see the campaign show up when filtering by "Pending Approval" on the Campaigns list, open it, approve it with a start date. Confirm it becomes `active` with that date shown.
3. As `red_lead`: on the now-active campaign, request removing the test with a justification. Confirm the test is still present.
4. As `manager`: approve the modification request. Confirm the test disappears from the campaign and the request shows "approved."
5. On the campaign detail page, check the Timeline section shows: created → submitted for approval → approved → modification requested → modification approved, in that order.
6. As `red_tech`: confirm you get no Submit/Approve/Request-Modification buttons anywhere (view-only).
- [ ] **Step 4: Report completion**
No commit for this task — it's verification only. If any step in the walkthrough fails, return to the relevant task above and fix before considering the feature done.
---