feat(web): read-only /api/v1/identities/* endpoints + repo methods

Second of the five-step identity-resolution substrate. Ships the API
surface against the empty AttackerIdentity table from commit 1 — every
endpoint returns empty/404 cleanly until the clusterer populates rows.

Routes (auth-gated, viewer role):
* GET /api/v1/identities — paginated list, excludes merged-out rows
* GET /api/v1/identities/{uuid} — detail; transparently follows
  merged_into_uuid to surface the canonical winner
* GET /api/v1/identities/{uuid}/observations — Attacker rows FK'd
  to the (resolved) identity uuid

Repository (BaseRepository abstract + SQLModelRepository concrete):
* get_identity_by_uuid (with merge-chain following, hop-bounded)
* list_identities / count_identities (excluding merged-out)
* list_observations_for_identity / count_observations_for_identity

Tests: 12 new (empty-table behavior, seeded data, merge-chain
resolution, repo-level smoke against real SQLite). Also fixes the
pre-existing test_base_repo_coverage failure (DEBT-041 added abstract
methods without updating the DummyRepo stub) — included here because
this PR adds 5 more abstract methods, fixing it as a bonus.

474 db/web/profiler/correlation tests green.
This commit is contained in:
2026-04-26 07:08:55 -04:00
parent 84c1ca9c9b
commit dc3d08dd41
9 changed files with 591 additions and 0 deletions

View File

@@ -55,6 +55,17 @@ class DummyRepo(BaseRepository):
async def get_attacker_artifacts(self, uuid): await super().get_attacker_artifacts(uuid)
async def get_attacker_transcripts(self, uuid): await super().get_attacker_transcripts(uuid)
async def get_session_log(self, sid): await super().get_session_log(sid)
# DEBT-041 / 3eb67c9 — attacker_intel re-key
async def find_credential_reuse_candidates(self, min_targets=2): await super().find_credential_reuse_candidates(min_targets); return []
async def get_attacker_intel_by_uuid(self, u): await super().get_attacker_intel_by_uuid(u)
async def get_unenriched_attackers(self, limit=100): await super().get_unenriched_attackers(limit)
async def upsert_attacker_intel(self, d): await super().upsert_attacker_intel(d); return ""
# Identity resolution (this PR)
async def get_identity_by_uuid(self, u): await super().get_identity_by_uuid(u)
async def list_identities(self, limit=50, offset=0): await super().list_identities(limit, offset); return []
async def count_identities(self): await super().count_identities(); return 0
async def list_observations_for_identity(self, u, limit=50, offset=0): await super().list_observations_for_identity(u, limit, offset); return []
async def count_observations_for_identity(self, u): await super().count_observations_for_identity(u); return 0
@pytest.mark.asyncio
async def test_base_repo_coverage():
@@ -113,6 +124,15 @@ async def test_base_repo_coverage():
await dr.get_attacker_artifacts("a")
await dr.get_attacker_transcripts("a")
await dr.get_session_log("a")
await dr.find_credential_reuse_candidates()
await dr.get_attacker_intel_by_uuid("a")
await dr.get_unenriched_attackers()
await dr.upsert_attacker_intel({"attacker_uuid": "a", "attacker_ip": "1.1.1.1"})
await dr.get_identity_by_uuid("a")
await dr.list_identities()
await dr.count_identities()
await dr.list_observations_for_identity("a")
await dr.count_observations_for_identity("a")
# Swarm methods: default NotImplementedError on BaseRepository. Covering
# them here keeps the coverage contract honest for the swarm CRUD surface.