Files
Aegis/backend/app/models/campaign.py
T
kitos 12a5484003 fix(campaigns): preserve modification-request audit row after test deletion
Approving a remove_test modification request deletes the underlying Test
row, which was cascading through test_id's ON DELETE CASCADE and wiping
out the request row itself before it could be read back. Changed to
ON DELETE SET NULL so the audit record (justification, reviewer,
decision) survives. Adds regression coverage plus double-approve/reject
idempotency tests.
2026-07-04 12:02:09 +02:00

293 lines
10 KiB
Python

"""Campaign and CampaignTest models.
Campaigns group multiple tests into a kill chain sequence,
enabling simulation of complete attack chains and APT emulations.
"""
# Import uuid
import uuid
# Import from sqlalchemy
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
# Import JSONB, UUID from sqlalchemy.dialects.postgresql
from sqlalchemy.dialects.postgresql import JSONB, UUID
# Import relationship from sqlalchemy.orm
from sqlalchemy.orm import relationship
# Import Base from app.database
from app.database import Base
# Define class Campaign
class Campaign(Base):
"""A campaign groups multiple tests into a sequenced attack chain.
Types:
- custom: manually created campaign
- apt_emulation: generated from a threat actor profile
- kill_chain: structured around kill chain phases
- compliance: targeting specific compliance requirements
Status:
- draft: being configured
- active: tests are being executed
- completed: all tests done
- archived: historical record
"""
# Assign __tablename__ = "campaigns"
__tablename__ = "campaigns"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign name = Column(String, nullable=False)
name = Column(String, nullable=False)
# Assign description = Column(Text, nullable=True)
description = Column(Text, nullable=True)
# Assign type = Column(String, nullable=False, default="custom") # custom, ap...
type = Column(String, nullable=False, default="custom") # custom, apt_emulation, kill_chain, compliance
# Assign threat_actor_id = Column(
threat_actor_id = Column(
UUID(as_uuid=True),
ForeignKey("threat_actors.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign status = Column(String, nullable=False, default="draft") # draft, activ...
status = Column(String, nullable=False, default="draft") # draft, active, completed, archived
# Assign created_by = Column(
created_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
start_date = Column(DateTime, nullable=True) # campaign won't activate before this date
scheduled_at = Column(DateTime, nullable=True)
approved_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
approved_at = Column(DateTime, nullable=True)
rejection_reason = Column(Text, nullable=True)
# Assign completed_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
# Assign target_platform = Column(String, nullable=True)
target_platform = Column(String, nullable=True)
# Assign tags = Column(JSONB, nullable=True, default=[])
tags = Column(JSONB, nullable=True, default=[])
# Assign created_at = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Assign data_classification = Column(String(20), nullable=False, server_default="internal")
data_classification = Column(String(20), nullable=False, server_default="internal")
# Recurring scheduling fields
is_recurring = Column(Boolean, default=False)
# Assign recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
# Assign next_run_at = Column(DateTime, nullable=True)
next_run_at = Column(DateTime, nullable=True)
# Assign last_run_at = Column(DateTime, nullable=True)
last_run_at = Column(DateTime, nullable=True)
# Assign parent_campaign_id = Column(
parent_campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Relationships
threat_actor = relationship("ThreatActor")
# Assign creator = relationship("User", foreign_keys=[created_by])
creator = relationship("User", foreign_keys=[created_by])
# Assign campaign_tests = relationship(
campaign_tests = relationship(
# Literal argument value
"CampaignTest",
# Keyword argument: back_populates
back_populates="campaign",
# Keyword argument: cascade
cascade="all, delete-orphan",
# Keyword argument: order_by
order_by="CampaignTest.order_index",
)
# Assign parent_campaign = relationship(
parent_campaign = relationship(
# Literal argument value
"Campaign",
# Keyword argument: remote_side
remote_side="Campaign.id",
# Keyword argument: foreign_keys
foreign_keys=[parent_campaign_id],
)
# Assign child_campaigns = relationship(
child_campaigns = relationship(
# Literal argument value
"Campaign",
# Keyword argument: foreign_keys
foreign_keys=[parent_campaign_id],
# Keyword argument: back_populates
back_populates="parent_campaign",
)
# Assign __table_args__ = (
__table_args__ = (
Index('ix_campaigns_status', 'status'),
Index('ix_campaigns_type', 'type'),
Index('ix_campaigns_threat_actor', 'threat_actor_id'),
Index('ix_campaigns_created_by', 'created_by'),
Index('ix_campaigns_next_run', 'next_run_at'),
)
# Kill chain phases in order (for sorting and validation)
KILL_CHAIN_PHASES = [
# Literal argument value
"reconnaissance",
# Literal argument value
"resource_development",
# Literal argument value
"initial_access",
# Literal argument value
"execution",
# Literal argument value
"persistence",
# Literal argument value
"privilege_escalation",
# Literal argument value
"defense_evasion",
# Literal argument value
"credential_access",
# Literal argument value
"discovery",
# Literal argument value
"lateral_movement",
# Literal argument value
"collection",
# Literal argument value
"command_and_control",
# Literal argument value
"exfiltration",
# Literal argument value
"impact",
]
# Define class CampaignTest
class CampaignTest(Base):
"""A test within a campaign, with ordering and dependency information.
``depends_on`` creates a self-referential chain (A -> B -> C).
Circular dependencies are validated at the service layer.
"""
# Assign __tablename__ = "campaign_tests"
__tablename__ = "campaign_tests"
# Assign id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Assign campaign_id = Column(
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign test_id = Column(
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
# Keyword argument: nullable
nullable=False,
)
# Assign order_index = Column(Integer, nullable=False, default=0)
order_index = Column(Integer, nullable=False, default=0)
# Assign depends_on = Column(
depends_on = Column(
UUID(as_uuid=True),
ForeignKey("campaign_tests.id", ondelete="SET NULL"),
# Keyword argument: nullable
nullable=True,
)
# Assign phase = Column(String, nullable=True) # kill chain phase
phase = Column(String, nullable=True) # kill chain phase
# Relationships
campaign = relationship("Campaign", back_populates="campaign_tests")
# Assign test = relationship("Test")
test = relationship("Test")
# Assign dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
dependency = relationship("CampaignTest", remote_side="CampaignTest.id")
# Assign __table_args__ = (
__table_args__ = (
Index('ix_campaign_tests_campaign', 'campaign_id'),
Index('ix_campaign_tests_test', 'test_id'),
)
class CampaignModificationRequest(Base):
"""A lead's request to add or remove a test from an already-active campaign.
The underlying ``CampaignTest`` row is only created/deleted once a
manager approves the request — see ``approve_modification_request``
in ``campaign_crud_service.py``.
"""
__tablename__ = "campaign_modification_requests"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="CASCADE"),
nullable=False,
)
requested_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
action = Column(String, nullable=False) # add_test, remove_test
# SET NULL (not CASCADE): approving a "remove_test" request deletes the
# underlying Test row (see remove_test_from_campaign), and this request
# row is the audit record of that decision — it must survive the test's
# deletion, not be wiped out along with it.
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="SET NULL"),
nullable=True,
)
order_index = Column(Integer, nullable=True)
phase = Column(String, nullable=True)
justification = Column(Text, nullable=False)
status = Column(String, nullable=False, default="pending") # pending, approved, rejected
reviewed_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
reviewed_at = Column(DateTime, nullable=True)
review_notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
campaign = relationship("Campaign")
test = relationship("Test")
requester = relationship("User", foreign_keys=[requested_by])
reviewer = relationship("User", foreign_keys=[reviewed_by])
__table_args__ = (
Index("ix_campaign_mod_requests_campaign", "campaign_id"),
Index("ix_campaign_mod_requests_status", "status"),
)