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

@@ -21,6 +21,9 @@ from .attackers.api_get_attacker_transcripts import router as attacker_transcrip
from .attackers.api_get_attacker_smtp_targets import router as attacker_smtp_targets_router
from .attackers.api_get_attacker_mail import router as attacker_mail_router
from .attackers.api_get_attacker_intel import router as attacker_intel_router
from .identities.api_list_identities import router as identities_list_router
from .identities.api_get_identity_detail import router as identity_detail_router
from .identities.api_list_identity_observations import router as identity_observations_router
from .transcripts import transcripts_router
from .config.api_get_config import router as config_get_router
from .config.api_update_config import router as config_update_router
@@ -84,6 +87,14 @@ api_router.include_router(attacker_smtp_targets_router)
api_router.include_router(attacker_mail_router)
api_router.include_router(attacker_intel_router)
# Identity Resolution (read-only; populated by the clusterer worker —
# see development/IDENTITY_RESOLUTION.md). Empty until the clusterer
# ships; the API surface lands first so frontend + downstream work
# can target a stable shape.
api_router.include_router(identities_list_router)
api_router.include_router(identity_detail_router)
api_router.include_router(identity_observations_router)
# Observability
api_router.include_router(stats_router)
api_router.include_router(stream_router)

View File

View File

@@ -0,0 +1,44 @@
"""GET /api/v1/identities/{uuid} — single identity row.
Soft-merge handling: if the requested UUID has merged_into_uuid set,
the repository follows the chain and returns the winner. Callers always
receive the canonical identity for any UUID that has ever been part of
the merge tree.
Returns 404 against an empty/unknown UUID — expected response while the
clusterer hasn't run yet.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/identities/{uuid}",
tags=["Identity Resolution"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Identity not found"},
},
)
@_traced("api.get_identity_detail")
async def get_identity_detail(
uuid: str,
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
identity = await repo.get_identity_by_uuid(uuid)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
# Cheap aggregates the IdentityDetail page surfaces. Counted off the
# FK rather than maintained in observation_count so the answer is
# always live (the denormalized field can lag the clusterer briefly).
identity["observation_count_live"] = await repo.count_observations_for_identity(
identity["uuid"]
)
return identity

View File

@@ -0,0 +1,35 @@
"""GET /api/v1/identities — paginated list of resolved identities.
Returns an empty list while the clusterer hasn't run yet (the
identities table ships empty in the schema-only PR). See
development/IDENTITY_RESOLUTION.md.
"""
from typing import Any
from fastapi import APIRouter, Depends, Query
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/identities",
tags=["Identity Resolution"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
422: {"description": "Validation error"},
},
)
@_traced("api.list_identities")
async def list_identities(
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0, le=2147483647),
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
"""Paginated identity list, newest-updated first."""
data = await repo.list_identities(limit=limit, offset=offset)
total = await repo.count_identities()
return {"total": total, "limit": limit, "offset": offset, "data": data}

View File

@@ -0,0 +1,48 @@
"""GET /api/v1/identities/{uuid}/observations — observations for an identity.
Returns the per-IP ``Attacker`` rows whose ``identity_id`` FK points at
this identity. The shape mirrors ``AttackersResponse`` so the frontend
can reuse the same row component as the main attackers list.
Empty result while the clusterer hasn't linked any observations yet.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from decnet.telemetry import traced as _traced
from decnet.web.dependencies import repo, require_viewer
router = APIRouter()
@router.get(
"/identities/{uuid}/observations",
tags=["Identity Resolution"],
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Identity not found"},
},
)
@_traced("api.list_identity_observations")
async def list_identity_observations(
uuid: str,
limit: int = Query(50, ge=1, le=1000),
offset: int = Query(0, ge=0, le=2147483647),
user: dict = Depends(require_viewer),
) -> dict[str, Any]:
# 404 if the identity itself doesn't exist. Otherwise return the
# observations linked to it (which may be empty — a freshly-formed
# identity briefly has no observations yet from the FK side).
identity = await repo.get_identity_by_uuid(uuid)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
# If the requested uuid was merged, return observations under the
# winner's uuid (which is what get_identity_by_uuid resolves to).
canonical_uuid = identity["uuid"]
data = await repo.list_observations_for_identity(
canonical_uuid, limit=limit, offset=offset
)
total = await repo.count_observations_for_identity(canonical_uuid)
return {"total": total, "limit": limit, "offset": offset, "data": data}