refactor(intel): re-key attacker_intel on attacker_uuid (closes DEBT-041)

The threat-intel surface was IP-keyed on day one as an expedient — the
worker is woken by IP-bearing bus events. ANTI's call: don't carry that
debt. NO IPs as primary keys anywhere on the attacker-intel surface.

Schema:
- attacker_uuid is now the canonical key — UNIQUE + FK to attackers.uuid.
- attacker_ip stays as a denormalised, indexed, NON-UNIQUE value column.
  Updated on every upsert; useful for SIEM payloads and audit lookups,
  but explicitly NOT a key. Model docstring says so.
- Pre-v1, no Alembic migration needed. SQLModel.metadata.create_all()
  builds the new shape on fresh DBs.

Repo:
- upsert_attacker_intel now keys on attacker_uuid.
- get_attacker_intel_by_ip → get_attacker_intel_by_uuid.
- get_unenriched_attacker_ips → get_unenriched_attackers, returning
  [{uuid, ip}] tuples so the worker writes by UUID and dispatches
  provider calls by IP without a second round-trip.

Worker:
- _enrich_one(uuid, ip, ...) — UUID lands on the row, IP rides for
  provider egress.
- attacker.intel.enriched bus payload gains attacker_uuid alongside
  attacker_ip — webhook → SIEM consumers benefit; no removal.

API:
- GET /api/v1/attackers/{ip}/intel deleted outright (rip-and-replace,
  never deployed beyond dev).
- GET /api/v1/attackers/{uuid}/intel is the only public route, matching
  every other /attackers/* route.

Frontend:
- <IntelPanel uuid={id!} /> uses the URL param directly, fetches in
  parallel with the rest of AttackerDetail rather than waiting on
  attacker.ip.

Tests: re-keyed in place, 39 passed (same coverage as before the
refactor). Provider-impl tests untouched.

DEBT-041: closed in DEBT.md (entry preserved as historical rationale,
summary table flipped to , remaining-open list shortened by one).
This commit is contained in:
2026-04-26 05:35:29 -04:00
parent a009549326
commit 3eb67c9400
10 changed files with 161 additions and 97 deletions

View File

@@ -1,4 +1,4 @@
"""GET /api/v1/attackers/{ip}/intel — latest threat-intel row for an IP."""
"""GET /api/v1/attackers/{uuid}/intel — latest threat-intel row for an attacker."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
@@ -10,27 +10,29 @@ router = APIRouter()
@router.get(
"/attackers/{ip}/intel",
"/attackers/{uuid}/intel",
tags=["Attacker Profiles"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "No intel cached for this IP"},
404: {"description": "No intel cached for this attacker"},
},
)
@_traced("api.get_attacker_intel")
async def get_attacker_intel(
ip: str,
uuid: str,
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Return the most recent cached threat-intel verdict for ``ip``.
"""Return the most recent cached threat-intel verdict for an attacker.
The row is populated out-of-band by the ``decnet enrich`` worker
(typically within seconds of first observation, sub-second when the
bus is healthy). 404 means either the worker has not run yet or the
IP has never been observed by DECNET.
UUID does not correspond to an attacker DECNET has seen.
"""
record = await repo.get_attacker_intel_by_ip(ip)
record = await repo.get_attacker_intel_by_uuid(uuid)
if not record:
raise HTTPException(status_code=404, detail="No intel cached for this IP")
raise HTTPException(
status_code=404, detail="No intel cached for this attacker",
)
return record