feat(db): Campaign SQLModel + repo write/read methods
Adds the campaigns table and the BaseRepository / SQLModelRepository methods that the campaign-clusterer worker (next commit) needs to populate it. Mirrors the AttackerIdentity layer: schema_version from day one for federation gossip, soft-merge via merged_into_uuid with a chain-walking get_campaign_by_uuid, list_campaigns excluding merged- out rows while list_all_campaigns returns the unfiltered set for the revoke pass. attacker_identities.campaign_id gets a real FK now that the target table exists.
This commit is contained in:
@@ -39,6 +39,10 @@ from .attackers import (
|
||||
from .attacker_intel import (
|
||||
AttackerIntel,
|
||||
)
|
||||
from .campaigns import (
|
||||
Campaign,
|
||||
CampaignsResponse,
|
||||
)
|
||||
from .deploy import (
|
||||
DeployIniRequest,
|
||||
DeployResponse,
|
||||
|
||||
@@ -122,9 +122,12 @@ class AttackerIdentity(SQLModel, table=True):
|
||||
__tablename__ = "attacker_identities"
|
||||
uuid: str = Field(primary_key=True)
|
||||
schema_version: int = Field(default=1)
|
||||
# Set by the campaign clusterer, downstream effort. The campaigns
|
||||
# table doesn't exist yet — no FK constraint, just a soft pointer.
|
||||
campaign_id: Optional[str] = Field(default=None, index=True)
|
||||
# Set by the campaign clusterer. The ``campaigns`` table now
|
||||
# exists; this is a real FK. Nullable until the campaign clusterer
|
||||
# has run on this identity row.
|
||||
campaign_id: Optional[str] = Field(
|
||||
default=None, foreign_key="campaigns.uuid", index=True
|
||||
)
|
||||
first_seen_at: Optional[datetime] = Field(default=None, index=True)
|
||||
last_seen_at: Optional[datetime] = Field(default=None, index=True)
|
||||
created_at: datetime = Field(
|
||||
|
||||
80
decnet/web/db/models/campaigns.py
Normal file
80
decnet/web/db/models/campaigns.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Campaign — operation-level grouping of resolved attacker identities."""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import Column, Text
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Campaign(SQLModel, table=True):
|
||||
"""
|
||||
Campaign — one operation, one or more identities.
|
||||
|
||||
Sits one level above ``AttackerIdentity``: an actor (identity) may
|
||||
appear in multiple campaigns over time, and a campaign may have
|
||||
several distinct identities cooperating (e.g. a night-shift and
|
||||
day-shift operator on the same job — fixture F5 multi_operator).
|
||||
|
||||
Populated by the campaign clusterer worker (downstream of identity
|
||||
resolution). Empty rows are valid; the table ships empty until the
|
||||
clusterer lands. ``schema_version`` is non-negotiable from day one
|
||||
for the same federation-gossip reason ``AttackerIdentity`` carries
|
||||
one — bumping campaign-level feature definitions without a version
|
||||
field silently poisons cross-operator gossip in V2.
|
||||
|
||||
See ``development/CAMPAIGN_CLUSTERING.md`` for the signal taxonomy
|
||||
(phase-handoff, shared-infra, temporal overlap, cohort).
|
||||
"""
|
||||
__tablename__ = "campaigns"
|
||||
uuid: str = Field(primary_key=True)
|
||||
schema_version: int = Field(default=1)
|
||||
first_seen_at: Optional[datetime] = Field(default=None, index=True)
|
||||
last_seen_at: Optional[datetime] = Field(default=None, index=True)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), index=True
|
||||
)
|
||||
# Campaign-cohesion score from the clusterer. Range [0, 1]; null
|
||||
# until the clusterer writes. Higher = more confident the linked
|
||||
# identities are part of the same operation.
|
||||
confidence: Optional[float] = Field(default=None)
|
||||
# Denormalized count of FK'd ``AttackerIdentity`` rows.
|
||||
identity_count: int = Field(default=0)
|
||||
# Aggregated fingerprint summary across member identities. Same
|
||||
# JSON-serialized list[str] in TEXT shape as
|
||||
# ``AttackerIdentity.{ja3,hassh,payload_simhashes,c2_endpoints}`` —
|
||||
# federation gossip wants the same wire shape at every layer.
|
||||
ja3_hashes: Optional[str] = Field(
|
||||
default=None, sa_column=Column("ja3_hashes", Text, nullable=True)
|
||||
)
|
||||
hassh_hashes: Optional[str] = Field(
|
||||
default=None, sa_column=Column("hassh_hashes", Text, nullable=True)
|
||||
)
|
||||
payload_simhashes: Optional[str] = Field(
|
||||
default=None, sa_column=Column("payload_simhashes", Text, nullable=True)
|
||||
)
|
||||
c2_endpoints: Optional[str] = Field(
|
||||
default=None, sa_column=Column("c2_endpoints", Text, nullable=True)
|
||||
)
|
||||
# Soft-merge audit trail — same revocable-merge pattern as
|
||||
# ``AttackerIdentity.merged_into_uuid``. When the clusterer
|
||||
# collapses two campaigns, the loser's row stays in place with this
|
||||
# set to the winner's UUID; resolvers follow the chain.
|
||||
merged_into_uuid: Optional[str] = Field(
|
||||
default=None, foreign_key="campaigns.uuid", index=True
|
||||
)
|
||||
# Operator-editable free-form notes — annotation surface for
|
||||
# human analysts ("APT-XX Q2 campaign", "matches CTI report 5678").
|
||||
notes: Optional[str] = Field(
|
||||
default=None, sa_column=Column("notes", Text, nullable=True)
|
||||
)
|
||||
|
||||
|
||||
class CampaignsResponse(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
data: List[dict[str, Any]]
|
||||
Reference in New Issue
Block a user